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

tpalfy 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 2708fc1cfb3 NIFI-15697 - Fix inconsistent versioning state when using 
external controller services in versioned Process Groups (#11006)
2708fc1cfb3 is described below

commit 2708fc1cfb349d983d0ae8a3a004936e2ee5c324
Author: Pierre Villard <[email protected]>
AuthorDate: Mon Jun 1 01:13:09 2026 -0700

    NIFI-15697 - Fix inconsistent versioning state when using external 
controller services in versioned Process Groups (#11006)
    
    Signed-off-by: Tamas Palfy <[email protected]>
---
 .../StandardVersionedComponentSynchronizer.java    |   2 +-
 .../apache/nifi/groups/StandardProcessGroup.java   | 109 +++++--
 .../java/org/apache/nifi/groups/ProcessGroup.java  |   7 -
 .../serialization/VersionedFlowSynchronizer.java   |   2 +-
 .../controller/service/mock/MockProcessGroup.java  |   5 -
 .../apache/nifi/web/StandardNiFiServiceFacade.java |  13 +-
 .../nifi/web/StandardNiFiServiceFacadeTest.java    |   3 -
 .../registry/flow/diff/StandardFlowComparator.java |  19 +-
 .../flow/diff/TestStandardFlowComparator.java      |  74 ++++-
 .../nifi/registry/service/RegistryService.java     |   2 +-
 .../ExternalControllerServiceVersioningIT.java     | 328 +++++++++++++++++++++
 11 files changed, 489 insertions(+), 75 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 d5f1db9d7be..73fed55d269 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
@@ -329,7 +329,7 @@ public class StandardVersionedComponentSynchronizer 
implements VersionedComponen
         final ComparableDataFlow proposedFlow = new 
StandardComparableDataFlow("Proposed Flow", 
versionedExternalFlow.getFlowContents());
 
         final PropertyDecryptor decryptor = options.getPropertyDecryptor();
-        final FlowComparator flowComparator = new 
StandardFlowComparator(localFlow, proposedFlow, group.getAncestorServiceIds(),
+        final FlowComparator flowComparator = new 
StandardFlowComparator(localFlow, proposedFlow,
             new StaticDifferenceDescriptor(), decryptor::decrypt, 
options.getComponentComparisonIdLookup(), FlowComparatorVersionedStrategy.DEEP);
         final FlowComparison flowComparison = flowComparator.compare();
 
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 3abd69197bf..c6de28a3083 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
@@ -67,9 +67,13 @@ 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;
@@ -2662,6 +2666,13 @@ public final class StandardProcessGroup implements 
ProcessGroup {
                     }
                 });
 
+            // When an ancestor controller service is removed, any descendant 
versioned PG whose
+            // committed snapshot referenced that service needs its cached 
differences invalidated,
+            // even if no component currently references the deleted service 
(e.g., the processor
+            // was already switched to a different service before the old one 
was deleted).
+            findAllProcessGroups(pg -> pg.getVersionControlInformation() != 
null)
+                .forEach(ProcessGroup::onComponentModified);
+
             scheduler.submitFrameworkTask(() -> 
stateManagerProvider.onComponentRemoved(service.getIdentifier()));
 
             removed = true;
@@ -3830,6 +3841,7 @@ 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) {
@@ -3989,26 +4001,6 @@ public final class StandardProcessGroup implements 
ProcessGroup {
         }
     }
 
-    @Override
-    public Set<String> getAncestorServiceIds() {
-        final Set<String> ancestorServiceIds;
-        ProcessGroup parentGroup = getParent();
-
-        if (parentGroup == null) {
-            ancestorServiceIds = Collections.emptySet();
-        } else {
-            // We want to map the Controller Service to its Versioned 
Component ID, if it has one.
-            // If it does not have one, we want to generate it in the same way 
that our Flow Mapper does
-            // because this allows us to find the Controller Service when 
doing a Flow Diff.
-            ancestorServiceIds = 
parentGroup.getControllerServices(true).stream()
-                .map(cs -> cs.getVersionedComponentId().orElse(
-                    
VersionedComponentFlowMapper.generateVersionedComponentId(cs.getIdentifier())))
-                .collect(Collectors.toSet());
-        }
-
-        return ancestorServiceIds;
-    }
-
     private String generateUuid(final String propposedId, final String 
destinationGroupId, final String seed) {
         long msb = UUID.nameUUIDFromBytes((propposedId + 
destinationGroupId).getBytes(StandardCharsets.UTF_8)).getMostSignificantBits();
 
@@ -4025,6 +4017,81 @@ 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();
 
@@ -4053,7 +4120,7 @@ public final class StandardProcessGroup implements 
ProcessGroup {
             final ComparableDataFlow currentFlow = new 
StandardComparableDataFlow("Local Flow", versionedGroup);
             final ComparableDataFlow snapshotFlow = new 
StandardComparableDataFlow("Versioned Flow", vci.getFlowSnapshot());
 
-            final FlowComparator flowComparator = new 
StandardFlowComparator(snapshotFlow, currentFlow, getAncestorServiceIds(),
+            final FlowComparator flowComparator = new 
StandardFlowComparator(snapshotFlow, currentFlow,
                 new EvolvingDifferenceDescriptor(), encryptor::decrypt, 
VersionedComponent::getIdentifier, FlowComparatorVersionedStrategy.SHALLOW);
             final FlowComparison comparison = flowComparator.compare();
             final Collection<FlowDifference> comparisonDifferences = 
comparison.getDifferences();
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/groups/ProcessGroup.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/groups/ProcessGroup.java
index 91a0a295aa4..80af8eaaf3e 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/groups/ProcessGroup.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/groups/ProcessGroup.java
@@ -603,13 +603,6 @@ public interface ProcessGroup extends 
ComponentAuthorizable, Positionable, Versi
      */
     Funnel findFunnel(String id);
 
-    /**
-     * Gets a collection of identifiers representing all ancestor controller 
services
-     *
-     * @return collection of ancestor controller service identifiers
-     */
-    Set<String> getAncestorServiceIds();
-
     /**
      * @param id of the Controller Service
      * @param includeDescendantGroups whether or not to include descendant 
process groups
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 ba409ee7d44..b78957214f7 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
@@ -512,7 +512,7 @@ public class VersionedFlowSynchronizer implements 
FlowSynchronizer {
             toSet(existingVersionedFlow.getRegistries())
         );
 
-        final FlowComparator flowComparator = new 
StandardFlowComparator(localDataFlow, clusterDataFlow, Collections.emptySet(),
+        final FlowComparator flowComparator = new 
StandardFlowComparator(localDataFlow, clusterDataFlow,
             differenceDescriptor, encryptor::decrypt, 
VersionedComponent::getInstanceIdentifier, 
FlowComparatorVersionedStrategy.DEEP);
         return flowComparator.compare();
     }
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/service/mock/MockProcessGroup.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/service/mock/MockProcessGroup.java
index 6beb29d409e..40622926a10 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/service/mock/MockProcessGroup.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/service/mock/MockProcessGroup.java
@@ -445,11 +445,6 @@ public class MockProcessGroup implements ProcessGroup {
         return null;
     }
 
-    @Override
-    public Set<String> getAncestorServiceIds() {
-        return null;
-    }
-
     @Override
     public ControllerServiceNode findControllerService(final String id, final 
boolean includeDescendants, final boolean includeAncestors) {
         return serviceMap.get(id);
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 c13966b16ab..1a33481c2c7 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
@@ -6274,7 +6274,6 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
         final FlowComparator flowComparator = new StandardFlowComparator(
                 new StandardComparableDataFlow("Flow A", flowContentsA),
                 new StandardComparableDataFlow("Flow B", flowContentsB),
-                Collections.emptySet(), // Replacement of an external 
ControllerService is recognized as property change
                 new ConciseEvolvingDifferenceDescriptor(),
                 Function.identity(),
                 VersionedComponent::getIdentifier,
@@ -6354,6 +6353,12 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
                     versionControlInfo.getFlowIdentifier(), 
versionControlInfo.getVersion());
             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) {
@@ -6366,8 +6371,7 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
         final ComparableDataFlow localFlow = new 
StandardComparableDataFlow("Local Flow", localGroup);
         final ComparableDataFlow registryFlow = new 
StandardComparableDataFlow("Versioned Flow", registryGroup);
 
-        final Set<String> ancestorServiceIds = 
processGroup.getAncestorServiceIds();
-        final FlowComparator flowComparator = new 
StandardFlowComparator(registryFlow, localFlow, ancestorServiceIds, new 
ConciseEvolvingDifferenceDescriptor(),
+        final FlowComparator flowComparator = new 
StandardFlowComparator(registryFlow, localFlow, new 
ConciseEvolvingDifferenceDescriptor(),
                 Function.identity(), VersionedComponent::getIdentifier, 
FlowComparatorVersionedStrategy.SHALLOW);
         final FlowComparison flowComparison = flowComparator.compare();
 
@@ -6530,8 +6534,7 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
         final ComparableDataFlow localFlow = new 
StandardComparableDataFlow("Current Flow", localContents);
         final ComparableDataFlow proposedFlow = new 
StandardComparableDataFlow("New Flow", updatedSnapshot.getFlowContents());
 
-        final Set<String> ancestorServiceIds = group.getAncestorServiceIds();
-        final FlowComparator flowComparator = new 
StandardFlowComparator(localFlow, proposedFlow, ancestorServiceIds, new 
StaticDifferenceDescriptor(),
+        final FlowComparator flowComparator = new 
StandardFlowComparator(localFlow, proposedFlow, new 
StaticDifferenceDescriptor(),
                 Function.identity(), VersionedComponent::getIdentifier, 
FlowComparatorVersionedStrategy.DEEP);
         final FlowComparison comparison = flowComparator.compare();
 
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
index c1cba635aec..f034c3e0d5e 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
@@ -341,8 +341,6 @@ public class StandardNiFiServiceFacadeTest {
         final String groupId = UUID.randomUUID().toString();
         final ProcessGroup processGroup = mock(ProcessGroup.class);
         
when(processGroupDAO.getProcessGroup(groupId)).thenReturn(processGroup);
-        
when(processGroup.getAncestorServiceIds()).thenReturn(Collections.emptySet());
-
         final FlowManager flowManager = mock(FlowManager.class);
         final ExtensionManager extensionManager = mock(ExtensionManager.class);
         when(flowController.getFlowManager()).thenReturn(flowManager);
@@ -376,7 +374,6 @@ public class StandardNiFiServiceFacadeTest {
         final FlowComparator flowComparator = new StandardFlowComparator(
                 localFlow,
                 proposedFlow,
-                Collections.emptySet(),
                 new StaticDifferenceDescriptor(),
                 Function.identity(),
                 VersionedComponent::getIdentifier,
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-flow-diff/src/main/java/org/apache/nifi/registry/flow/diff/StandardFlowComparator.java
 
b/nifi-registry/nifi-registry-core/nifi-registry-flow-diff/src/main/java/org/apache/nifi/registry/flow/diff/StandardFlowComparator.java
index 875d595b456..02902225a0c 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-flow-diff/src/main/java/org/apache/nifi/registry/flow/diff/StandardFlowComparator.java
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-flow-diff/src/main/java/org/apache/nifi/registry/flow/diff/StandardFlowComparator.java
@@ -70,18 +70,16 @@ public class StandardFlowComparator implements 
FlowComparator {
 
     private final ComparableDataFlow flowA;
     private final ComparableDataFlow flowB;
-    private final Set<String> externallyAccessibleServiceIds;
     private final DifferenceDescriptor differenceDescriptor;
     private final Function<String, String> propertyDecryptor;
     private final Function<VersionedComponent, String> idLookup;
     private final FlowComparatorVersionedStrategy 
flowComparatorVersionedStrategy;
 
-    public StandardFlowComparator(final ComparableDataFlow flowA, final 
ComparableDataFlow flowB, final Set<String> externallyAccessibleServiceIds,
+    public StandardFlowComparator(final ComparableDataFlow flowA, final 
ComparableDataFlow flowB,
                                   final DifferenceDescriptor 
differenceDescriptor, final Function<String, String> propertyDecryptor,
                                   final Function<VersionedComponent, String> 
idLookup, final FlowComparatorVersionedStrategy 
flowComparatorVersionedStrategy) {
         this.flowA = flowA;
         this.flowB = flowB;
-        this.externallyAccessibleServiceIds = externallyAccessibleServiceIds;
         this.differenceDescriptor = differenceDescriptor;
         this.propertyDecryptor = propertyDecryptor;
         this.idLookup = idLookup;
@@ -427,21 +425,6 @@ public class StandardFlowComparator implements 
FlowComparator {
                     
differences.add(difference(DifferenceType.PROPERTY_REMOVED, componentA, 
componentB, key, displayName, valueA, valueB));
                 }
             } else if (valueA != null && !valueA.equals(valueB)) {
-                // If the property in Flow A references a Controller Service 
that is not available in the flow
-                // and the property in Flow B references a Controller Service 
that is available in its environment
-                // but not part of the Versioned Flow, then we do not want to 
consider this to be a Flow Difference.
-                // This is typically the case when a flow is versioned in one 
instance, referencing an external Controller Service,
-                // and then imported into another NiFi instance. When 
imported, the property does not point to any existing Controller
-                // Service, and the user must then point the property an 
existing Controller Service. We don't want to consider the
-                // flow as having changed, since it is an environment-specific 
change (similar to how we handle variables).
-                if (descriptor != null && 
descriptor.getIdentifiesControllerService()) {
-                    final boolean accessibleA = 
externallyAccessibleServiceIds.contains(valueA);
-                    final boolean accessibleB = 
externallyAccessibleServiceIds.contains(valueB);
-                    if (!accessibleA && accessibleB) {
-                        return;
-                    }
-                }
-
                 final boolean aParameterized = isParameterReference(valueA);
                 final boolean bParameterized = isParameterReference(valueB);
                 if (aParameterized && !bParameterized) {
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-flow-diff/src/test/java/org/apache/nifi/registry/flow/diff/TestStandardFlowComparator.java
 
b/nifi-registry/nifi-registry-core/nifi-registry-flow-diff/src/test/java/org/apache/nifi/registry/flow/diff/TestStandardFlowComparator.java
index b068b43e780..a14a7cb7fe9 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-flow-diff/src/test/java/org/apache/nifi/registry/flow/diff/TestStandardFlowComparator.java
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-flow-diff/src/test/java/org/apache/nifi/registry/flow/diff/TestStandardFlowComparator.java
@@ -28,6 +28,7 @@ import org.apache.nifi.flow.VersionedParameter;
 import org.apache.nifi.flow.VersionedParameterContext;
 import org.apache.nifi.flow.VersionedProcessGroup;
 import org.apache.nifi.flow.VersionedProcessor;
+import org.apache.nifi.flow.VersionedPropertyDescriptor;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
@@ -63,7 +64,7 @@ public class TestStandardFlowComparator {
         final Function<String, String> decryptor = encryptedToDecrypted::get;
         final ComparableDataFlow flowA = new StandardComparableDataFlow("Flow 
A", new VersionedProcessGroup());
         final ComparableDataFlow flowB = new StandardComparableDataFlow("Flow 
B", new VersionedProcessGroup());
-        comparator = new StandardFlowComparator(flowA, flowB, 
Collections.emptySet(),
+        comparator = new StandardFlowComparator(flowA, flowB,
             new StaticDifferenceDescriptor(), decryptor, 
VersionedComponent::getInstanceIdentifier, 
FlowComparatorVersionedStrategy.SHALLOW);
     }
 
@@ -223,7 +224,7 @@ public class TestStandardFlowComparator {
 
         // Testing when a child PG is added and the child PG contains 
components
 
-        comparator = new StandardFlowComparator(flowA, flowB, 
Collections.emptySet(),
+        comparator = new StandardFlowComparator(flowA, flowB,
                 new StaticDifferenceDescriptor(), decryptor, 
VersionedComponent::getIdentifier, FlowComparatorVersionedStrategy.SHALLOW);
 
         final Set<FlowDifference> diffShallowChildPgAdded = 
comparator.compare().getDifferences();
@@ -232,7 +233,7 @@ public class TestStandardFlowComparator {
                 .anyMatch(difference -> difference.getDifferenceType() == 
DifferenceType.COMPONENT_ADDED
                         && difference.getComponentB().getComponentType() == 
ComponentType.PROCESS_GROUP));
 
-        comparator = new StandardFlowComparator(flowA, flowB, 
Collections.emptySet(),
+        comparator = new StandardFlowComparator(flowA, flowB,
                 new StaticDifferenceDescriptor(), decryptor, 
VersionedComponent::getIdentifier, FlowComparatorVersionedStrategy.DEEP);
         final Set<FlowDifference> diffDeepChildPgAdded = 
comparator.compare().getDifferences();
         assertEquals(15, diffDeepChildPgAdded.size());
@@ -257,7 +258,7 @@ public class TestStandardFlowComparator {
 
         // Testing when a child PG is removed and the child PG contains 
components
 
-        comparator = new StandardFlowComparator(flowB, flowA, 
Collections.emptySet(),
+        comparator = new StandardFlowComparator(flowB, flowA,
                 new StaticDifferenceDescriptor(), decryptor, 
VersionedComponent::getIdentifier, FlowComparatorVersionedStrategy.SHALLOW);
 
         final Set<FlowDifference> diffShallowChildPgRemoved = 
comparator.compare().getDifferences();
@@ -266,7 +267,7 @@ public class TestStandardFlowComparator {
                 .anyMatch(difference -> difference.getDifferenceType() == 
DifferenceType.COMPONENT_REMOVED
                         && difference.getComponentA().getComponentType() == 
ComponentType.PROCESS_GROUP));
 
-        comparator = new StandardFlowComparator(flowB, flowA, 
Collections.emptySet(),
+        comparator = new StandardFlowComparator(flowB, flowA,
                 new StaticDifferenceDescriptor(), decryptor, 
VersionedComponent::getIdentifier, FlowComparatorVersionedStrategy.DEEP);
         final Set<FlowDifference> diffDeepChildPgRemoved = 
comparator.compare().getDifferences();
         assertEquals(4, diffDeepChildPgRemoved.size());
@@ -340,7 +341,6 @@ public class TestStandardFlowComparator {
         final StandardFlowComparator testComparator = new 
StandardFlowComparator(
                 registryFlow,
                 localFlow,
-                Collections.emptySet(),
                 new StaticDifferenceDescriptor(),
                 Function.identity(),
                 VersionedComponent::getIdentifier,
@@ -407,7 +407,6 @@ public class TestStandardFlowComparator {
         final StandardFlowComparator testComparator = new 
StandardFlowComparator(
                 registryFlow,
                 localFlow,
-                Collections.emptySet(),
                 new StaticDifferenceDescriptor(),
                 Function.identity(),
                 VersionedComponent::getIdentifier,
@@ -478,7 +477,6 @@ public class TestStandardFlowComparator {
         final StandardFlowComparator shallowComparator = new 
StandardFlowComparator(
                 registryFlow,
                 localFlow,
-                Collections.emptySet(),
                 new StaticDifferenceDescriptor(),
                 Function.identity(),
                 VersionedComponent::getIdentifier,
@@ -551,7 +549,6 @@ public class TestStandardFlowComparator {
         final StandardFlowComparator comparator = new StandardFlowComparator(
                 registryFlow,
                 localFlow,
-                Collections.emptySet(),
                 new StaticDifferenceDescriptor(),
                 Function.identity(),
                 VersionedComponent::getIdentifier,
@@ -622,7 +619,7 @@ public class TestStandardFlowComparator {
 
         // DEEP strategy: both PG2 addition AND processor addition should be 
reported
         final StandardFlowComparator deepComparator = new 
StandardFlowComparator(
-                registryFlow, localFlow, Collections.emptySet(), new 
StaticDifferenceDescriptor(),
+                registryFlow, localFlow, new StaticDifferenceDescriptor(),
                 Function.identity(), VersionedComponent::getIdentifier, 
FlowComparatorVersionedStrategy.DEEP);
 
         final Set<FlowDifference> deepDifferences = 
deepComparator.compare().getDifferences();
@@ -638,7 +635,7 @@ public class TestStandardFlowComparator {
 
         // SHALLOW strategy: only PG2 addition is reported (processor inside 
is not expanded)
         final StandardFlowComparator shallowComparator = new 
StandardFlowComparator(
-                registryFlow, localFlow, Collections.emptySet(), new 
StaticDifferenceDescriptor(),
+                registryFlow, localFlow, new StaticDifferenceDescriptor(),
                 Function.identity(), VersionedComponent::getIdentifier, 
FlowComparatorVersionedStrategy.SHALLOW);
 
         final Set<FlowDifference> shallowDifferences = 
shallowComparator.compare().getDifferences();
@@ -688,7 +685,7 @@ public class TestStandardFlowComparator {
         final ComparableDataFlow localFlow = new 
StandardComparableDataFlow("Local Flow", localRoot);
 
         final StandardFlowComparator testComparator = new 
StandardFlowComparator(
-                registryFlow, localFlow, Collections.emptySet(), new 
StaticDifferenceDescriptor(),
+                registryFlow, localFlow, new StaticDifferenceDescriptor(),
                 Function.identity(), VersionedComponent::getIdentifier, 
FlowComparatorVersionedStrategy.SHALLOW);
 
         final Set<FlowDifference> differences = 
testComparator.compare().getDifferences();
@@ -784,7 +781,7 @@ public class TestStandardFlowComparator {
         final ComparableDataFlow localFlow = new 
StandardComparableDataFlow("Local Flow", localParent);
 
         final StandardFlowComparator testComparator = new 
StandardFlowComparator(
-                registryFlow, localFlow, Collections.emptySet(), new 
StaticDifferenceDescriptor(),
+                registryFlow, localFlow, new StaticDifferenceDescriptor(),
                 Function.identity(), VersionedComponent::getIdentifier, 
FlowComparatorVersionedStrategy.SHALLOW);
 
         final Set<FlowDifference> differences = 
testComparator.compare().getDifferences();
@@ -805,6 +802,57 @@ public class TestStandardFlowComparator {
                 "Should only have 1 difference (child PG version coordinate 
change). Differences found: " + differences);
     }
 
+    /**
+     * Verifies that changing a processor's controller service property from 
one external service
+     * to another is detected as a PROPERTY_CHANGED difference. Previously, 
the comparator had
+     * suppression logic that could incorrectly hide such changes when the old 
service ID
+     * was no longer accessible.
+     */
+    @Test
+    public void testExternalControllerServicePropertyChangeDetected() {
+        final String processorId = "processor1";
+        final String servicePropertyKey = "my-service";
+        final String oldServiceId = "service-X-id";
+        final String newServiceId = "service-Y-id";
+
+        final VersionedPropertyDescriptor serviceDescriptor = new 
VersionedPropertyDescriptor();
+        serviceDescriptor.setName(servicePropertyKey);
+        serviceDescriptor.setIdentifiesControllerService(true);
+
+        final VersionedProcessor registryProcessor = new VersionedProcessor();
+        registryProcessor.setIdentifier(processorId);
+        registryProcessor.setScheduledState(ScheduledState.ENABLED);
+        registryProcessor.setProperties(Map.of(servicePropertyKey, 
oldServiceId));
+        registryProcessor.setPropertyDescriptors(Map.of(servicePropertyKey, 
serviceDescriptor));
+
+        final VersionedProcessor localProcessor = new VersionedProcessor();
+        localProcessor.setIdentifier(processorId);
+        localProcessor.setScheduledState(ScheduledState.ENABLED);
+        localProcessor.setProperties(Map.of(servicePropertyKey, newServiceId));
+        localProcessor.setPropertyDescriptors(Map.of(servicePropertyKey, 
serviceDescriptor));
+
+        final VersionedProcessGroup registryRoot = new VersionedProcessGroup();
+        registryRoot.setIdentifier("rootPG");
+        registryRoot.getProcessors().add(registryProcessor);
+
+        final VersionedProcessGroup localRoot = new VersionedProcessGroup();
+        localRoot.setIdentifier("rootPG");
+        localRoot.getProcessors().add(localProcessor);
+
+        final ComparableDataFlow registryFlow = new 
StandardComparableDataFlow("Versioned Flow", registryRoot);
+        final ComparableDataFlow localFlow = new 
StandardComparableDataFlow("Local Flow", localRoot);
+
+        final StandardFlowComparator testComparator = new 
StandardFlowComparator(
+                registryFlow, localFlow, new StaticDifferenceDescriptor(),
+                Function.identity(), VersionedComponent::getIdentifier, 
FlowComparatorVersionedStrategy.SHALLOW);
+
+        final Set<FlowDifference> differences = 
testComparator.compare().getDifferences();
+
+        assertEquals(1, differences.size());
+        final FlowDifference diff = differences.iterator().next();
+        assertEquals(DifferenceType.PROPERTY_CHANGED, 
diff.getDifferenceType());
+    }
+
     private VersionedFlowCoordinates createVersionedFlowCoordinates() {
         final VersionedFlowCoordinates coordinates = new 
VersionedFlowCoordinates();
         coordinates.setRegistryId("registry");
diff --git 
a/nifi-registry/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/RegistryService.java
 
b/nifi-registry/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/RegistryService.java
index 4726d8ec228..1a0c2a22f2f 100644
--- 
a/nifi-registry/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/RegistryService.java
+++ 
b/nifi-registry/nifi-registry-core/nifi-registry-framework/src/main/java/org/apache/nifi/registry/service/RegistryService.java
@@ -925,7 +925,7 @@ public class RegistryService {
 
         // Compare the two versions of the flow
         final FlowComparator flowComparator = new 
StandardFlowComparator(comparableFlowA, comparableFlowB,
-                null, new ConciseEvolvingDifferenceDescriptor(), 
Function.identity(), VersionedComponent::getIdentifier, 
FlowComparatorVersionedStrategy.DEEP);
+                new ConciseEvolvingDifferenceDescriptor(), 
Function.identity(), VersionedComponent::getIdentifier, 
FlowComparatorVersionedStrategy.DEEP);
         final FlowComparison flowComparison = flowComparator.compare();
 
         final VersionedFlowDifference result = new VersionedFlowDifference();
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
new file mode 100644
index 00000000000..d74000f7935
--- /dev/null
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/ExternalControllerServiceVersioningIT.java
@@ -0,0 +1,328 @@
+/*
+ * 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.tests.system.registry;
+
+import org.apache.nifi.tests.system.NiFiClientUtil;
+import org.apache.nifi.tests.system.NiFiSystemIT;
+import org.apache.nifi.toolkit.client.NiFiClientException;
+import org.apache.nifi.web.api.dto.ControllerServiceDTO;
+import org.apache.nifi.web.api.dto.DifferenceDTO;
+import org.apache.nifi.web.api.dto.VersionControlInformationDTO;
+import org.apache.nifi.web.api.dto.flow.FlowDTO;
+import org.apache.nifi.web.api.entity.ConnectionEntity;
+import org.apache.nifi.web.api.entity.ControllerServiceEntity;
+import org.apache.nifi.web.api.entity.FlowComparisonEntity;
+import org.apache.nifi.web.api.entity.FlowRegistryClientEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupFlowEntity;
+import org.apache.nifi.web.api.entity.ProcessorEntity;
+import org.apache.nifi.web.api.entity.VersionControlInformationEntity;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * System tests verifying correct version-control behavior when a versioned 
Process Group
+ * references an external Controller Service (one defined in an ancestor 
group).
+ *
+ * Several tests simulate a cross-instance (dev to prod) scenario by 
committing a flow
+ * that references an external service, then deleting the original PG and 
service, creating
+ * a new service with the same name (but a different ID), and importing the 
flow from the
+ * registry. This reproduces the ID mismatch that occurs when a flow versioned 
on one NiFi
+ * instance is imported into another instance that has a same-named external 
service.
+ */
+public class ExternalControllerServiceVersioningIT extends NiFiSystemIT {
+    private static final String TEST_FLOWS_BUCKET = "test-flows";
+    private static final String COUNT_SERVICE_TYPE = "StandardCountService";
+
+    /**
+     * Simulates a cross-instance import: a flow is committed referencing an 
external service,
+     * 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.
+     */
+    @Test
+    public void testCrossInstanceImportWithExternalServiceShowsUpToDate() 
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-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);
+
+        final ControllerServiceEntity prodService = 
util.createControllerService(COUNT_SERVICE_TYPE, "root");
+        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());
+
+        waitForVersionedFlowState(imported.getId(), "root", "UP_TO_DATE");
+
+        final FlowComparisonEntity localMods = 
getNifiClient().getProcessGroupClient().getLocalModifications(imported.getId());
+        assertTrue(localMods.getComponentDifferences().isEmpty(),
+                "After cross-instance import, 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
+     * service are deleted, a new service with the same name (different ID) is 
created, then
+     * the flow is re-imported at v1 and upgraded to v2.
+     *
+     * NiFi preserves existing external service references during upgrades, so 
the external
+     * service reference stays the same in both versions. After upgrade, the 
flow should be
+     * UP_TO_DATE because the only change (scheduling period) is applied by 
the synchronizer
+     * and the external service reference matches in both the local flow and 
the VCI snapshot.
+     */
+    @Test
+    public void testCrossInstanceUpgradeWithExternalServiceShowsUpToDate() 
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");
+        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-upgrade");
+        util.assertFlowUpToDate(child.getId());
+
+        counter = util.updateProcessorSchedulingPeriod(counter, "10 sec");
+        util.saveFlowVersion(child, registryClient, vci);
+        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);
+
+        final ControllerServiceEntity prodService = 
util.createControllerService(COUNT_SERVICE_TYPE, "root");
+        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(), "1");
+
+        waitForVersionedFlowState(imported.getId(), "root", "STALE");
+
+        util.changeFlowVersion(imported.getId(), "2");
+
+        waitForVersionedFlowState(imported.getId(), "root", "UP_TO_DATE");
+
+        final FlowComparisonEntity localMods = 
getNifiClient().getProcessGroupClient().getLocalModifications(imported.getId());
+        assertTrue(localMods.getComponentDifferences().isEmpty(),
+                "After cross-instance upgrade, Show Local Changes should 
report no differences");
+    }
+
+    /**
+     * 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.
+     *
+     * The two services have different names so the name-based resolver cannot 
falsely reconcile them.
+     */
+    @Test
+    public void 
testSwitchExternalServiceAndDeleteOriginalShowsLocalModification() throws 
NiFiClientException, IOException, InterruptedException {
+        final FlowRegistryClientEntity registryClient = registerClient();
+        final NiFiClientUtil util = getClientUtil();
+
+        final ControllerServiceEntity serviceA = 
util.createControllerService(COUNT_SERVICE_TYPE, "root");
+        util.enableControllerService(serviceA);
+
+        final ProcessGroupEntity child = util.createProcessGroup("Child", 
"root");
+        final ProcessorEntity counter = util.createProcessor("CountFlowFiles", 
child.getId());
+        util.updateProcessorProperties(counter, 
Collections.singletonMap("Count Service", serviceA.getComponent().getId()));
+        final ProcessorEntity terminate = 
util.createProcessor("TerminateFlowFile", child.getId());
+        util.createConnection(counter, terminate, "success");
+
+        util.startVersionControl(child, registryClient, TEST_FLOWS_BUCKET, 
"nifi-15697-flow");
+        util.assertFlowUpToDate(child.getId());
+
+        ControllerServiceEntity serviceB = 
util.createControllerService(COUNT_SERVICE_TYPE, "root");
+        serviceB = renameControllerService(serviceB, "AlternateCountService");
+        util.enableControllerService(serviceB);
+
+        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");
+
+        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");
+    }
+
+    /**
+     * Verifies that when a versioned PG references an external service and 
the user has NOT
+     * modified anything, both the state badge and the dialog report 
UP_TO_DATE / no changes.
+     */
+    @Test
+    public void testExternalServiceReferenceWithoutModificationShowsUpToDate() 
throws NiFiClientException, IOException, InterruptedException {
+        final FlowRegistryClientEntity registryClient = registerClient();
+        final NiFiClientUtil util = getClientUtil();
+
+        final ControllerServiceEntity service = 
util.createControllerService(COUNT_SERVICE_TYPE, "root");
+        util.enableControllerService(service);
+
+        final ProcessGroupEntity child = util.createProcessGroup("Child", 
"root");
+        final ProcessorEntity counter = util.createProcessor("CountFlowFiles", 
child.getId());
+        util.updateProcessorProperties(counter, 
Collections.singletonMap("Count Service", service.getComponent().getId()));
+        final ProcessorEntity terminate = 
util.createProcessor("TerminateFlowFile", child.getId());
+        util.createConnection(counter, terminate, "success");
+
+        util.startVersionControl(child, registryClient, TEST_FLOWS_BUCKET, 
"ext-svc-unchanged");
+        util.assertFlowUpToDate(child.getId());
+
+        final FlowComparisonEntity localMods = 
getNifiClient().getProcessGroupClient().getLocalModifications(child.getId());
+        assertTrue(localMods.getComponentDifferences().isEmpty(),
+                "Show Local Changes should report no differences for an 
unmodified flow");
+    }
+
+    /**
+     * Deletes only the connections and processors within a Process Group, 
without touching
+     * Controller Services (which may be inherited from ancestor groups).
+     */
+    private void deleteProcessGroupContents(final String groupId) throws 
NiFiClientException, IOException {
+        final ProcessGroupFlowEntity flowEntity = 
getNifiClient().getFlowClient().getProcessGroup(groupId);
+        final FlowDTO flowDto = flowEntity.getProcessGroupFlow().getFlow();
+
+        for (final ConnectionEntity connection : flowDto.getConnections()) {
+            connection.setDisconnectedNodeAcknowledged(true);
+            getNifiClient().getConnectionClient().deleteConnection(connection);
+        }
+
+        for (final ProcessorEntity processor : flowDto.getProcessors()) {
+            processor.setDisconnectedNodeAcknowledged(true);
+            getNifiClient().getProcessorClient().deleteProcessor(processor);
+        }
+    }
+
+    private ControllerServiceEntity renameControllerService(final 
ControllerServiceEntity service, final String newName)
+            throws NiFiClientException, IOException {
+        final ControllerServiceDTO dto = new ControllerServiceDTO();
+        dto.setId(service.getId());
+        dto.setName(newName);
+
+        final ControllerServiceEntity entity = new ControllerServiceEntity();
+        entity.setId(service.getId());
+        entity.setComponent(dto);
+        entity.setRevision(service.getRevision());
+
+        return 
getNifiClient().getControllerServicesClient().updateControllerService(entity);
+    }
+
+    private void deleteControllerService(final ControllerServiceEntity 
service) throws NiFiClientException, IOException, InterruptedException {
+        getClientUtil().disableControllerService(service);
+        waitForControllerServiceState(service.getId(), "DISABLED");
+        final ControllerServiceEntity refreshed = 
getNifiClient().getControllerServicesClient().getControllerService(service.getId());
+        
getNifiClient().getControllerServicesClient().deleteControllerService(refreshed);
+    }
+
+    private void waitForControllerServiceState(final String serviceId, final 
String expectedState) throws NiFiClientException, IOException, 
InterruptedException {
+        final long maxWait = System.currentTimeMillis() + 30_000;
+        while (System.currentTimeMillis() < maxWait) {
+            final ControllerServiceEntity entity = 
getNifiClient().getControllerServicesClient().getControllerService(serviceId);
+            if 
(expectedState.equalsIgnoreCase(entity.getComponent().getState())) {
+                return;
+            }
+            Thread.sleep(100L);
+        }
+        throw new AssertionError("Controller Service " + serviceId + " did not 
reach " + expectedState + " state within 30 seconds");
+    }
+
+    private void waitForVersionedFlowState(final String groupId, final String 
parentGroupId, final String expectedState)
+            throws NiFiClientException, IOException, InterruptedException {
+
+        final long maxWait = System.currentTimeMillis() + 60_000;
+        while (System.currentTimeMillis() < maxWait) {
+            final String state = 
getClientUtil().getVersionedFlowState(groupId, parentGroupId);
+            if (expectedState.equalsIgnoreCase(state)) {
+                return;
+            }
+            Thread.sleep(500L);
+        }
+
+        final String finalState = 
getClientUtil().getVersionedFlowState(groupId, parentGroupId);
+        if (expectedState.equalsIgnoreCase(finalState)) {
+            return;
+        }
+
+        if ("LOCALLY_MODIFIED".equalsIgnoreCase(finalState) || 
"LOCALLY_MODIFIED_AND_STALE".equalsIgnoreCase(finalState)) {
+            final FlowComparisonEntity localMods = 
getNifiClient().getProcessGroupClient().getLocalModifications(groupId);
+            final StringBuilder sb = new StringBuilder();
+            localMods.getComponentDifferences().stream()
+                    .flatMap(dto -> dto.getDifferences().stream())
+                    .map(DifferenceDTO::getDifference)
+                    .forEach(diff -> sb.append("\n  - ").append(diff));
+            throw new AssertionError("Expected versioned flow state " + 
expectedState + " but was " + finalState
+                    + " with modifications:" + sb);
+        }
+
+        throw new AssertionError("Expected versioned flow state " + 
expectedState + " but was " + finalState);
+    }
+}

Reply via email to