You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@nifi.apache.org by ex...@apache.org on 2026/07/23 13:52:34 UTC

(nifi) branch main updated: NIFI-16058 Treat external Controller Service reference changes in versioned flows as environmental (#11383)

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 5a6c47278d7 NIFI-16058 Treat external Controller Service reference changes in versioned flows as environmental (#11383)
5a6c47278d7 is described below

commit 5a6c47278d70a4c98a3b1403a80bc1ff748bf586
Author: Pierre Villard <pi...@gmail.com>
AuthorDate: Thu Jul 23 15:52:27 2026 +0200

    NIFI-16058 Treat external Controller Service reference changes in versioned flows as environmental (#11383)
    
    Signed-off-by: David Handermann <ex...@apache.org>
---
 .../apache/nifi/groups/StandardProcessGroup.java   |  82 +---------------
 .../apache/nifi/util/FlowDifferenceFilters.java    | 100 +++++++++++++++++++-
 .../nifi/util/TestFlowDifferenceFilters.java       |  84 +++++++++++++++++
 .../apache/nifi/web/StandardNiFiServiceFacade.java |   5 -
 .../org/apache/nifi/web/api/dto/DtoFactory.java    |   2 +-
 .../ExternalControllerServiceVersioningIT.java     | 103 ++++++++++++++++-----
 6 files changed, 260 insertions(+), 116 deletions(-)

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 1aa7d4c5959..b20a5787ece 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
@@ -68,13 +68,9 @@ import org.apache.nifi.controller.service.ControllerServiceState;
 import org.apache.nifi.controller.service.StandardConfigurationContext;
 import org.apache.nifi.encrypt.PropertyEncryptor;
 import org.apache.nifi.flow.ExecutionEngine;
-import org.apache.nifi.flow.ExternalControllerServiceReference;
 import org.apache.nifi.flow.VersionedComponent;
-import org.apache.nifi.flow.VersionedControllerService;
 import org.apache.nifi.flow.VersionedExternalFlow;
 import org.apache.nifi.flow.VersionedProcessGroup;
-import org.apache.nifi.flow.VersionedProcessor;
-import org.apache.nifi.flow.VersionedPropertyDescriptor;
 import org.apache.nifi.flow.synchronization.StandardVersionedComponentSynchronizer;
 import org.apache.nifi.flow.synchronization.VersionedFlowSynchronizationContext;
 import org.apache.nifi.lifecycle.ProcessorStopLifecycleMethods;
@@ -3867,7 +3863,6 @@ public final class StandardProcessGroup implements ProcessGroup {
                 final FlowSnapshotContainer registrySnapshotContainer = flowRegistry.getFlowContents(
                         FlowRegistryClientContextFactory.getAnonymousContext(), flowVersionLocation, false);
                 final RegisteredFlowSnapshot registrySnapshot = registrySnapshotContainer.getFlowSnapshot();
-                resolveExternalServiceReferences(registrySnapshot);
                 final VersionedProcessGroup registryFlow = registrySnapshot.getFlowContents();
                 vci.setFlowSnapshot(registryFlow);
             } catch (final IOException | FlowRegistryException e) {
@@ -4094,81 +4089,6 @@ public final class StandardProcessGroup implements ProcessGroup {
         return uuid.toString();
     }
 
-    private void resolveExternalServiceReferences(final RegisteredFlowSnapshot snapshot) {
-        final Map<String, ExternalControllerServiceReference> externalRefs = snapshot.getExternalControllerServices();
-        if (externalRefs == null || externalRefs.isEmpty()) {
-            return;
-        }
-
-        final ProcessGroup parentGroup = getParent();
-        if (parentGroup == null) {
-            return;
-        }
-
-        final Map<String, String> serviceNameToVersionedId = new HashMap<>();
-        for (final ControllerServiceNode serviceNode : parentGroup.getControllerServices(true)) {
-            final String versionedId = serviceNode.getVersionedComponentId().orElse(
-                    VersionedComponentFlowMapper.generateVersionedComponentId(serviceNode.getIdentifier()));
-            serviceNameToVersionedId.put(serviceNode.getName(), versionedId);
-        }
-
-        final Map<String, String> foreignToLocalId = new HashMap<>();
-        for (final Map.Entry<String, ExternalControllerServiceReference> entry : externalRefs.entrySet()) {
-            final String foreignId = entry.getKey();
-            final String serviceName = entry.getValue().getName();
-            final String localId = serviceNameToVersionedId.get(serviceName);
-            if (localId != null && !localId.equals(foreignId)) {
-                foreignToLocalId.put(foreignId, localId);
-            }
-        }
-
-        if (!foreignToLocalId.isEmpty()) {
-            replaceExternalServiceIds(snapshot.getFlowContents(), foreignToLocalId);
-        }
-    }
-
-    private void replaceExternalServiceIds(final VersionedProcessGroup group, final Map<String, String> foreignToLocalId) {
-        if (group.getProcessors() != null) {
-            for (final VersionedProcessor processor : group.getProcessors()) {
-                replaceServicePropertyIds(processor.getProperties(), processor.getPropertyDescriptors(), foreignToLocalId);
-            }
-        }
-
-        if (group.getControllerServices() != null) {
-            for (final VersionedControllerService service : group.getControllerServices()) {
-                replaceServicePropertyIds(service.getProperties(), service.getPropertyDescriptors(), foreignToLocalId);
-            }
-        }
-
-        if (group.getProcessGroups() != null) {
-            for (final VersionedProcessGroup child : group.getProcessGroups()) {
-                replaceExternalServiceIds(child, foreignToLocalId);
-            }
-        }
-    }
-
-    private void replaceServicePropertyIds(final Map<String, String> properties, final Map<String, VersionedPropertyDescriptor> descriptors,
-                                           final Map<String, String> foreignToLocalId) {
-        if (properties == null || descriptors == null) {
-            return;
-        }
-
-        for (final Map.Entry<String, String> entry : properties.entrySet()) {
-            final String propertyValue = entry.getValue();
-            if (propertyValue == null) {
-                continue;
-            }
-
-            final VersionedPropertyDescriptor descriptor = descriptors.get(entry.getKey());
-            if (descriptor != null && descriptor.getIdentifiesControllerService()) {
-                final String localId = foreignToLocalId.get(propertyValue);
-                if (localId != null) {
-                    entry.setValue(localId);
-                }
-            }
-        }
-    }
-
     private Set<FlowDifference> getModifications() {
         final StandardVersionControlInformation vci = versionControlInfo.get();
 
@@ -4202,7 +4122,7 @@ public final class StandardProcessGroup implements ProcessGroup {
             final FlowComparison comparison = flowComparator.compare();
             final Collection<FlowDifference> comparisonDifferences = comparison.getDifferences();
             final FlowDifferenceFilters.EnvironmentalChangeContext environmentalContext =
-                FlowDifferenceFilters.buildEnvironmentalChangeContext(comparisonDifferences, flowManager);
+                FlowDifferenceFilters.buildEnvironmentalChangeContext(comparisonDifferences, versionedGroup, flowManager);
 
             final Set<FlowDifference> differences = comparisonDifferences.stream()
                 .filter(difference -> !FlowDifferenceFilters.isEnvironmentalChange(difference, versionedGroup, flowManager, environmentalContext))
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/util/FlowDifferenceFilters.java b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/util/FlowDifferenceFilters.java
index 1da9653ae50..72fd48d7637 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/util/FlowDifferenceFilters.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/util/FlowDifferenceFilters.java
@@ -48,7 +48,9 @@ import org.apache.nifi.registry.flow.diff.FlowDifference;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedComponent;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedConnection;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedControllerService;
+import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedProcessGroup;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedProcessor;
+import org.apache.nifi.registry.flow.mapping.VersionedComponentFlowMapper;
 
 import java.util.ArrayList;
 import java.util.Collection;
@@ -104,7 +106,8 @@ public class FlowDifferenceFilters {
             || isPropertyParameterizationRename(difference, evaluatedContext)
             || isPropertyRenameWithMatchingValue(difference, evaluatedContext)
             || isSelectedRelationshipChangeForNewRelationship(difference, flowManager)
-            || isPropertyAddedFromMigration(difference, flowManager);
+            || isPropertyAddedFromMigration(difference, flowManager)
+            || isExternalControllerServiceReferenceChange(difference, flowManager, evaluatedContext);
     }
 
     /**
@@ -705,10 +708,17 @@ public class FlowDifferenceFilters {
     }
 
     public static EnvironmentalChangeContext buildEnvironmentalChangeContext(final Collection<FlowDifference> differences, final FlowManager flowManager) {
+        return buildEnvironmentalChangeContext(differences, null, flowManager);
+    }
+
+    public static EnvironmentalChangeContext buildEnvironmentalChangeContext(final Collection<FlowDifference> differences, final VersionedProcessGroup localGroup,
+                                                                             final FlowManager flowManager) {
         if (differences == null || differences.isEmpty() || flowManager == null) {
             return EnvironmentalChangeContext.empty();
         }
 
+        final Set<String> ancestorControllerServiceIds = computeAncestorControllerServiceIds(localGroup, flowManager);
+
         final Map<String, List<PropertyDiffInfo>> parameterizedAddsByComponent = new HashMap<>();
         final Map<String, List<PropertyDiffInfo>> parameterizationRemovalsByComponent = new HashMap<>();
         final Map<String, List<PropertyDiffInfo>> controllerServicePropertyAddsByValue = new HashMap<>();
@@ -854,11 +864,83 @@ public class FlowDifferenceFilters {
             }
         }
 
-        if (serviceIdsWithMatchingAdditions.isEmpty() && parameterizedPropertyRenameDifferences.isEmpty() && propertyRenamesWithMatchingValues.isEmpty()) {
+        if (serviceIdsWithMatchingAdditions.isEmpty() && parameterizedPropertyRenameDifferences.isEmpty() && propertyRenamesWithMatchingValues.isEmpty()
+                && ancestorControllerServiceIds.isEmpty()) {
             return EnvironmentalChangeContext.empty();
         }
 
-        return new EnvironmentalChangeContext(serviceIdsWithMatchingAdditions, parameterizedPropertyRenameDifferences, propertyRenamesWithMatchingValues);
+        return new EnvironmentalChangeContext(serviceIdsWithMatchingAdditions, parameterizedPropertyRenameDifferences, propertyRenamesWithMatchingValues,
+                ancestorControllerServiceIds);
+    }
+
+    /**
+     * Determines whether a Flow Difference represents a change to a property that references an <em>external</em> controller service
+     * (one defined outside the versioned Process Group, e.g. in an ancestor group). Such a reference is environment-specific: the same
+     * logical service typically has a different identifier in each environment, so pointing the property at a different external service
+     * is not treated as a local modification. The change is environmental only when the versioned-snapshot value does not resolve to a
+     * locally-accessible ancestor service while the local value does.
+     */
+    private static boolean isExternalControllerServiceReferenceChange(final FlowDifference difference, final FlowManager flowManager,
+                                                                      final EnvironmentalChangeContext context) {
+        if (difference.getDifferenceType() != DifferenceType.PROPERTY_CHANGED) {
+            return false;
+        }
+
+        final Set<String> ancestorServiceIds = context.ancestorControllerServiceIds();
+        if (ancestorServiceIds.isEmpty()) {
+            return false;
+        }
+
+        if (!isControllerServiceProperty(difference, flowManager)) {
+            return false;
+        }
+
+        final Optional<String> snapshotValue = getPropertyValue(difference, true);
+        final Optional<String> localValue = getPropertyValue(difference, false);
+        if (snapshotValue.isEmpty() || localValue.isEmpty()) {
+            return false;
+        }
+
+        // Parameter-referenced controller services are environment-portable through Parameter Contexts and are not evaluated here.
+        if (isParameterReference(snapshotValue.get()) || isParameterReference(localValue.get())) {
+            return false;
+        }
+
+        // Environmental only when the snapshot reference is not a locally-accessible ancestor controller service but the local reference
+        // is. A switch between two locally-accessible ancestor services remains a reported change, and a reference to a service defined
+        // inside the versioned Process Group is never in the ancestor set, so it always remains a reported change.
+        final boolean snapshotAccessible = ancestorServiceIds.contains(snapshotValue.get());
+        final boolean localAccessible = ancestorServiceIds.contains(localValue.get());
+        return !snapshotAccessible && localAccessible;
+    }
+
+    /**
+     * Computes the set of versioned component identifiers for all controller services accessible from the ancestors of the given (local)
+     * versioned Process Group -- i.e. services defined in the parent group and above. These identify references to external controller
+     * services. Returns an empty set when the group is not an instantiated (live-backed) group, so callers that pass a plain snapshot
+     * group get no suppression.
+     */
+    private static Set<String> computeAncestorControllerServiceIds(final VersionedProcessGroup localGroup, final FlowManager flowManager) {
+        if (flowManager == null || !(localGroup instanceof InstantiatedVersionedProcessGroup instantiatedGroup)) {
+            return Collections.emptySet();
+        }
+
+        final ProcessGroup liveGroup = flowManager.getGroup(instantiatedGroup.getInstanceIdentifier());
+        if (liveGroup == null) {
+            return Collections.emptySet();
+        }
+
+        final ProcessGroup parentGroup = liveGroup.getParent();
+        if (parentGroup == null) {
+            return Collections.emptySet();
+        }
+
+        final Set<String> ancestorServiceIds = new HashSet<>();
+        for (final ControllerServiceNode serviceNode : parentGroup.getControllerServices(true)) {
+            ancestorServiceIds.add(serviceNode.getVersionedComponentId().orElseGet(
+                    () -> VersionedComponentFlowMapper.generateVersionedComponentId(serviceNode.getIdentifier())));
+        }
+        return ancestorServiceIds;
     }
 
     public static boolean isControllerServiceCreatedForNewProperty(final FlowDifference difference, final EnvironmentalChangeContext context) {
@@ -1211,18 +1293,22 @@ public class FlowDifferenceFilters {
     }
 
     public static final class EnvironmentalChangeContext {
-        private static final EnvironmentalChangeContext EMPTY = new EnvironmentalChangeContext(Collections.emptySet(), Collections.emptySet(), Collections.emptySet());
+        private static final EnvironmentalChangeContext EMPTY =
+                new EnvironmentalChangeContext(Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet());
 
         private final Set<String> serviceIdsCreatedForNewProperties;
         private final Set<FlowDifference> parameterizedPropertyRenames;
         private final Set<FlowDifference> propertyRenamesWithMatchingValues;
+        private final Set<String> ancestorControllerServiceIds;
 
         private EnvironmentalChangeContext(final Set<String> serviceIdsCreatedForNewProperties,
                                            final Set<FlowDifference> parameterizedPropertyRenames,
-                                           final Set<FlowDifference> propertyRenamesWithMatchingValues) {
+                                           final Set<FlowDifference> propertyRenamesWithMatchingValues,
+                                           final Set<String> ancestorControllerServiceIds) {
             this.serviceIdsCreatedForNewProperties = Collections.unmodifiableSet(new HashSet<>(serviceIdsCreatedForNewProperties));
             this.parameterizedPropertyRenames = Collections.unmodifiableSet(new HashSet<>(parameterizedPropertyRenames));
             this.propertyRenamesWithMatchingValues = Collections.unmodifiableSet(new HashSet<>(propertyRenamesWithMatchingValues));
+            this.ancestorControllerServiceIds = Collections.unmodifiableSet(new HashSet<>(ancestorControllerServiceIds));
         }
 
         static EnvironmentalChangeContext empty() {
@@ -1240,5 +1326,9 @@ public class FlowDifferenceFilters {
         Set<FlowDifference> propertyRenamesWithMatchingValues() {
             return propertyRenamesWithMatchingValues;
         }
+
+        Set<String> ancestorControllerServiceIds() {
+            return ancestorControllerServiceIds;
+        }
     }
 }
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 81c45668eef..cbb5489ef13 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
@@ -44,6 +44,7 @@ import org.apache.nifi.registry.flow.diff.FlowDifference;
 import org.apache.nifi.registry.flow.diff.StandardFlowDifference;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedConnection;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedControllerService;
+import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedProcessGroup;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedProcessor;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
@@ -51,6 +52,7 @@ import org.mockito.Mockito;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -393,6 +395,88 @@ public class TestFlowDifferenceFilters {
         assertTrue(FlowDifferenceFilters.isEnvironmentalChange(controllerServiceDifference, null, flowManager, context));
     }
 
+    /**
+     * Changing a processor property that references an external (ancestor) controller service is not reported as a local
+     * modification, because the same logical service typically has a different identifier in each environment. This exercises the
+     * directional rule over the set of accessible ancestor controller service ids: suppression happens only when the versioned-snapshot
+     * reference is not a locally-accessible ancestor service while the local reference is.
+     */
+    @Test
+    public void testExternalControllerServiceReferenceChangeDirectionalRule() {
+        final String pgInstanceId = "pg-instance";
+        final String parentGroupId = "parent-group";
+        final String propertyName = "SSL Context Service";
+
+        final String ancestorServiceX = "ancestor-service-x";
+        final String ancestorServiceY = "ancestor-service-y";
+        final String foreignServiceId = "foreign-service-id";
+        final String internalServiceId = "internal-service-id";
+
+        // Two controller services defined in the parent (ancestor) group, i.e. external to the versioned Process Group.
+        final ControllerServiceNode serviceX = Mockito.mock(ControllerServiceNode.class);
+        Mockito.when(serviceX.getVersionedComponentId()).thenReturn(Optional.of(ancestorServiceX));
+        final ControllerServiceNode serviceY = Mockito.mock(ControllerServiceNode.class);
+        Mockito.when(serviceY.getVersionedComponentId()).thenReturn(Optional.of(ancestorServiceY));
+
+        final ProcessGroup parentGroup = Mockito.mock(ProcessGroup.class);
+        Mockito.when(parentGroup.getControllerServices(true)).thenReturn(Set.of(serviceX, serviceY));
+
+        final ProcessGroup liveGroup = Mockito.mock(ProcessGroup.class);
+        Mockito.when(liveGroup.getParent()).thenReturn(parentGroup);
+
+        final FlowManager flowManager = Mockito.mock(FlowManager.class);
+        Mockito.when(flowManager.getGroup(pgInstanceId)).thenReturn(liveGroup);
+
+        final InstantiatedVersionedProcessGroup localGroup = new InstantiatedVersionedProcessGroup(pgInstanceId, parentGroupId);
+
+        assertExternalServiceChange(localGroup, flowManager, parentGroupId, propertyName, foreignServiceId, ancestorServiceX, true,
+                "Switching from an unavailable external service to a local ancestor service must be treated as environmental");
+
+        assertExternalServiceChange(localGroup, flowManager, parentGroupId, propertyName, ancestorServiceY, ancestorServiceX, false,
+                "Switching between two locally-accessible ancestor services must remain a reported change");
+
+        assertExternalServiceChange(localGroup, flowManager, parentGroupId, propertyName, ancestorServiceX, internalServiceId, false,
+                "Switching to a service that is not an accessible ancestor (e.g. internal to the group) must remain a reported change");
+
+        assertExternalServiceChange(localGroup, flowManager, parentGroupId, propertyName, "foreign-1", "foreign-2", false,
+                "A change between two non-ancestor services must remain a reported change");
+    }
+
+    private void assertExternalServiceChange(final InstantiatedVersionedProcessGroup localGroup, final FlowManager flowManager,
+                                             final String groupId, final String propertyName, final String snapshotValue,
+                                             final String localValue, final boolean expectedEnvironmental, final String message) {
+        final VersionedPropertyDescriptor descriptor = new VersionedPropertyDescriptor();
+        descriptor.setName(propertyName);
+        descriptor.setDisplayName(propertyName);
+        descriptor.setDynamic(false);
+        descriptor.setIdentifiesControllerService(true);
+
+        final InstantiatedVersionedProcessor snapshotProcessor = new InstantiatedVersionedProcessor("processor-instance", groupId);
+        snapshotProcessor.setComponentType(ComponentType.PROCESSOR);
+        snapshotProcessor.setIdentifier("processor-instance");
+        snapshotProcessor.setProperties(Map.of(propertyName, snapshotValue));
+        snapshotProcessor.setPropertyDescriptors(Map.of(propertyName, descriptor));
+
+        final InstantiatedVersionedProcessor localProcessor = new InstantiatedVersionedProcessor("processor-instance", groupId);
+        localProcessor.setComponentType(ComponentType.PROCESSOR);
+        localProcessor.setIdentifier("processor-instance");
+        localProcessor.setProperties(Map.of(propertyName, localValue));
+        localProcessor.setPropertyDescriptors(Map.of(propertyName, descriptor));
+
+        final FlowDifference difference = new StandardFlowDifference(
+                DifferenceType.PROPERTY_CHANGED, snapshotProcessor, localProcessor, propertyName, snapshotValue, localValue,
+                "Property '" + propertyName + "' changed");
+
+        final FlowDifferenceFilters.EnvironmentalChangeContext context =
+                FlowDifferenceFilters.buildEnvironmentalChangeContext(List.of(difference), localGroup, flowManager);
+
+        if (expectedEnvironmental) {
+            assertTrue(FlowDifferenceFilters.isEnvironmentalChange(difference, localGroup, flowManager, context), message);
+        } else {
+            assertFalse(FlowDifferenceFilters.isEnvironmentalChange(difference, localGroup, flowManager, context), message);
+        }
+    }
+
     @Test
     public void testPropertyRenameWithParameterizationObservedAsEnvironmentalChange() {
         final FlowManager flowManager = Mockito.mock(FlowManager.class);
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 aca6949c908..ad6f1568e7c 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
@@ -6597,11 +6597,6 @@ public class StandardNiFiServiceFacade implements NiFiServiceFacade {
             final FlowSnapshotContainer flowSnapshotContainer = flowRegistry.getFlowContents(FlowRegistryClientContextFactory.getContextForUser(NiFiUserUtils.getNiFiUser()),
                     flowVersionLocation, true);
 
-            // Resolve external controller service references by name so that cross-instance
-            // ID differences do not appear as phantom local modifications.
-            final String parentGroupId = processGroup.getParent() == null ? processGroup.getIdentifier() : processGroup.getParent().getIdentifier();
-            controllerFacade.getControllerServiceResolver().resolveInheritedControllerServices(flowSnapshotContainer, parentGroupId, NiFiUserUtils.getNiFiUser());
-
             final RegisteredFlowSnapshot versionedFlowSnapshot = flowSnapshotContainer.getFlowSnapshot();
             registryGroup = versionedFlowSnapshot.getFlowContents();
         } catch (final IOException | FlowRegistryException e) {
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
index 1df40ad87db..5c2a35e18b7 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
@@ -2856,7 +2856,7 @@ public final class DtoFactory {
 
         final Collection<FlowDifference> comparisonDifferences = comparison.getDifferences();
         final FlowDifferenceFilters.EnvironmentalChangeContext environmentalContext =
-                FlowDifferenceFilters.buildEnvironmentalChangeContext(comparisonDifferences, flowManager);
+                FlowDifferenceFilters.buildEnvironmentalChangeContext(comparisonDifferences, localGroup, flowManager);
 
         for (final FlowDifference difference : comparisonDifferences) {
             // capture bundle differences and dedupe those differences
diff --git a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/ExternalControllerServiceVersioningIT.java b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/ExternalControllerServiceVersioningIT.java
index d74000f7935..1ee3a7a029a 100644
--- a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/ExternalControllerServiceVersioningIT.java
+++ b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/ExternalControllerServiceVersioningIT.java
@@ -61,9 +61,9 @@ public class ExternalControllerServiceVersioningIT extends NiFiSystemIT {
      * then the original PG and service are removed and a new service with the same name (but
      * different ID) is created before re-importing from the registry.
      *
-     * After import, the flow should be UP_TO_DATE because the external service was resolved
-     * by name during import and the cached snapshot should also be resolved.
-     * Both the state badge and the "Show Local Changes" dialog should agree.
+     * After import, the flow should be UP_TO_DATE: the committed reference points at the (now-removed) dev service id, which
+     * is not a locally-accessible ancestor service, while the local reference points at the same-named prod service, so the
+     * external-service reference change is treated as environment-specific. Both the badge and the dialog should agree.
      */
     @Test
     public void testCrossInstanceImportWithExternalServiceShowsUpToDate() throws NiFiClientException, IOException, InterruptedException {
@@ -106,6 +106,57 @@ public class ExternalControllerServiceVersioningIT extends NiFiSystemIT {
                 "After cross-instance import, Show Local Changes should report no differences");
     }
 
+    /**
+     * A flow committed against one external service is imported into an instance whose equivalent external service has a
+     * DIFFERENT name (and a different id). After pointing the processor at the local service, the flow is UP_TO_DATE with no
+     * local modifications, because an external controller service reference is environment-specific regardless of its name or id.
+     */
+    @Test
+    public void testCrossInstanceImportWithDifferentlyNamedExternalServiceShowsUpToDate() throws NiFiClientException, IOException, InterruptedException {
+        final FlowRegistryClientEntity registryClient = registerClient();
+        final NiFiClientUtil util = getClientUtil();
+
+        final ControllerServiceEntity devService = util.createControllerService(COUNT_SERVICE_TYPE, "root");
+        util.enableControllerService(devService);
+
+        final ProcessGroupEntity child = util.createProcessGroup("Child", "root");
+        final ProcessorEntity counter = util.createProcessor("CountFlowFiles", child.getId());
+        util.updateProcessorProperties(counter, Collections.singletonMap("Count Service", devService.getComponent().getId()));
+        final ProcessorEntity terminate = util.createProcessor("TerminateFlowFile", child.getId());
+        util.createConnection(counter, terminate, "success");
+
+        final VersionControlInformationEntity vci = util.startVersionControl(child, registryClient, TEST_FLOWS_BUCKET, "cross-instance-diff-name-flow");
+        util.assertFlowUpToDate(child.getId());
+        final VersionControlInformationDTO vciDto = vci.getVersionControlInformation();
+
+        getNifiClient().getVersionsClient().stopVersionControl(
+                getNifiClient().getProcessGroupClient().getProcessGroup(child.getId()));
+        deleteProcessGroupContents(child.getId());
+        getNifiClient().getProcessGroupClient().deleteProcessGroup(
+                getNifiClient().getProcessGroupClient().getProcessGroup(child.getId()));
+
+        deleteControllerService(devService);
+
+        ControllerServiceEntity prodService = util.createControllerService(COUNT_SERVICE_TYPE, "root");
+        prodService = renameControllerService(prodService, "DifferentlyNamedCountService");
+        assertNotEquals(devService.getComponent().getId(), prodService.getComponent().getId(),
+                "Prod service should have a different ID than dev service");
+        util.enableControllerService(prodService);
+
+        final ProcessGroupEntity imported = util.importFlowFromRegistry("root", vciDto.getRegistryId(),
+                vciDto.getBucketId(), vciDto.getFlowId(), vciDto.getVersion());
+
+        // Point the imported processor at the differently-named local external service.
+        final ProcessorEntity importedCounter = findProcessorByType(imported.getId(), "CountFlowFiles");
+        util.updateProcessorProperties(importedCounter, Collections.singletonMap("Count Service", prodService.getComponent().getId()));
+
+        waitForVersionedFlowState(imported.getId(), "root", "UP_TO_DATE");
+
+        final FlowComparisonEntity localMods = getNifiClient().getProcessGroupClient().getLocalModifications(imported.getId());
+        assertTrue(localMods.getComponentDifferences().isEmpty(),
+                "After pointing at a differently-named external service, Show Local Changes should report no differences");
+    }
+
     /**
      * Simulates a cross-instance upgrade where v1 and v2 both reference the same external
      * service but differ in a non-service property (scheduling period). The original PG and
@@ -167,17 +218,16 @@ public class ExternalControllerServiceVersioningIT extends NiFiSystemIT {
     }
 
     /**
-     * Reproduces the NIFI-15697 scenario on a single instance:
-     *
-     * 1. Create an external service "StandardCountService" and a child PG referencing it, commit.
-     * 2. Create a second external service "AlternateCountService", switch the processor to it.
-     * 3. Delete the original service.
-     * 4. Verify that both the state badge (LOCALLY_MODIFIED) and the dialog (shows the change) agree.
+     * Switching a processor's reference from one external service to another and then deleting the originally-referenced service
+     * keeps the state badge and the "Show Local Changes" dialog consistent at every step.
      *
-     * The two services have different names so the name-based resolver cannot falsely reconcile them.
+     * While both services exist, the switch is a genuine, reported local change (both are locally-accessible ancestor services).
+     * Once the originally-referenced service is removed, the committed reference no longer resolves to a local service, so the
+     * change becomes environment-specific and the flow returns to UP_TO_DATE. The badge and the dialog agree throughout -- there
+     * is never a badge that reports LOCALLY_MODIFIED while the dialog reports no changes.
      */
     @Test
-    public void testSwitchExternalServiceAndDeleteOriginalShowsLocalModification() throws NiFiClientException, IOException, InterruptedException {
+    public void testSwitchExternalServiceAndDeleteOriginalKeepsBadgeAndDialogConsistent() throws NiFiClientException, IOException, InterruptedException {
         final FlowRegistryClientEntity registryClient = registerClient();
         final NiFiClientUtil util = getClientUtil();
 
@@ -199,22 +249,19 @@ public class ExternalControllerServiceVersioningIT extends NiFiSystemIT {
 
         util.updateProcessorProperties(counter, Collections.singletonMap("Count Service", serviceB.getComponent().getId()));
 
-        String state = util.getVersionedFlowState(child.getId(), "root");
-        assertEquals("LOCALLY_MODIFIED", state, "After switching external service reference, PG should be LOCALLY_MODIFIED");
+        // While both external services exist, switching between them is a genuine local change; badge and dialog must agree.
+        final String stateAfterSwitch = util.getVersionedFlowState(child.getId(), "root");
+        assertEquals("LOCALLY_MODIFIED", stateAfterSwitch, "While both external services exist, switching the reference should be LOCALLY_MODIFIED");
+        assertFalse(getNifiClient().getProcessGroupClient().getLocalModifications(child.getId()).getComponentDifferences().isEmpty(),
+                "Badge is LOCALLY_MODIFIED, so the dialog must also report differences");
 
         deleteControllerService(serviceA);
 
-        state = util.getVersionedFlowState(child.getId(), "root");
-        assertEquals("LOCALLY_MODIFIED", state, "After deleting original service, PG should still be LOCALLY_MODIFIED");
-
-        final FlowComparisonEntity localMods = getNifiClient().getProcessGroupClient().getLocalModifications(child.getId());
-        assertFalse(localMods.getComponentDifferences().isEmpty(), "Show Local Changes should report differences");
-
-        final boolean hasPropertyValueChange = localMods.getComponentDifferences().stream()
-                .flatMap(dto -> dto.getDifferences().stream())
-                .map(DifferenceDTO::getDifferenceType)
-                .anyMatch(type -> type.contains("Property Value Changed"));
-        assertTrue(hasPropertyValueChange, "Differences should include a Property Value Changed difference");
+        // Once the originally-referenced external service is gone, the reference change is environment-specific: the flow
+        // returns to UP_TO_DATE and the dialog reports no differences. Badge and dialog agree (no stuck LOCALLY_MODIFIED).
+        waitForVersionedFlowState(child.getId(), "root", "UP_TO_DATE");
+        assertTrue(getNifiClient().getProcessGroupClient().getLocalModifications(child.getId()).getComponentDifferences().isEmpty(),
+                "After removing the original external service, the dialog must report no differences (consistent with the UP_TO_DATE badge)");
     }
 
     /**
@@ -262,6 +309,14 @@ public class ExternalControllerServiceVersioningIT extends NiFiSystemIT {
         }
     }
 
+    private ProcessorEntity findProcessorByType(final String groupId, final String simpleType) throws NiFiClientException, IOException {
+        final ProcessGroupFlowEntity flowEntity = getNifiClient().getFlowClient().getProcessGroup(groupId);
+        return flowEntity.getProcessGroupFlow().getFlow().getProcessors().stream()
+                .filter(processor -> processor.getComponent().getType().endsWith(simpleType))
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("Could not find processor of type " + simpleType + " in group " + groupId));
+    }
+
     private ControllerServiceEntity renameControllerService(final ControllerServiceEntity service, final String newName)
             throws NiFiClientException, IOException {
         final ControllerServiceDTO dto = new ControllerServiceDTO();