markap14 commented on code in PR #11813:
URL: https://github.com/apache/nifi/pull/11813#discussion_r4460865282


##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/migration/StandardControllerServiceFactory.java:
##########
@@ -123,6 +145,22 @@ public ControllerServiceNode create(final 
ControllerServiceCreationDetails creat
         return serviceNode;
     }
 
+    private @Nullable String generateVersionedId(final 
ControllerServiceCreationDetails creationDetails, final ControllerServiceNode 
serviceNode) {
+        if (owningGroupIdentity == null) {
+            return null;
+        }
+
+        final Map<String, String> sortedProperties = new TreeMap<>();
+        for (final Map.Entry<String, String> property : 
creationDetails.serviceProperties().entrySet()) {
+            if 
(!serviceNode.getPropertyDescriptor(property.getKey()).isSensitive()) {

Review Comment:
   [Claude Opus 5.5]
   
   This check runs before `serviceNode.migrateConfiguration(...)` on line 130, 
and that is where the service renames old property names to its current ones. 
For a name the service doesn't recognize, `getPropertyDescriptor` returns a 
non-sensitive placeholder, so the value is included in the hash.
   
   The two real migrations that pass secrets both use old names:
   - `AbstractAwsProcessor` passes `Secret Key`. 
`AWSCredentialsProviderControllerService` only renames it to `Secret Access 
Key` in its own `migrateProperties`.
   - `ProxyServiceMigration` passes `proxy-user-password`. 
`StandardProxyConfigurationService` calls it `Proxy User Password`.
   
   So the password goes into the hash. The author and each consumer get 
different IDs, the service is replaced on update and loses its state, and a 
hash of the password is saved in the registry.
   
   Please move this logic into the synchronizer, which runs after the service's 
migration (see my comment on lines 428-445 of 
`StandardVersionedComponentSynchronizer`). The factory shouldn't compute the ID 
at all.



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/migration/StandardControllerServiceFactory.java:
##########
@@ -104,7 +113,20 @@ public ControllerServiceNode create(final 
ControllerServiceCreationDetails creat
 
         serviceNode.setProperties(creationDetails.serviceProperties());
 
-        final ControllerServiceFactory serviceFactory = new 
StandardControllerServiceFactory(extensionManager, flowManager, 
serviceProvider, serviceNode);
+        final String versionedId = generateVersionedId(creationDetails, 
serviceNode);
+        if (versionedId != null) {
+            final Set<ControllerServiceNode> groupControllerServices = 
group.map(processGroup -> 
processGroup.getControllerServices(false)).orElse(Collections.emptySet());
+            final boolean occupied = groupControllerServices.stream()
+                    .map(ControllerServiceNode::getVersionedComponentId)
+                    .flatMap(Optional::stream)
+                    .anyMatch(versionedId::equals);
+
+            if (!occupied) {
+                serviceNode.setVersionedComponentId(versionedId);
+            }
+        }

Review Comment:
   [Claude Opus 5.5]
   
   This "first one created keeps the ID" rule can point a processor at another 
processor's service.
   
   Example: two AWS processors in the same group with different keys. With 
sensitive values properly left out, both services hash to the same ID. 
Whichever processor migrates first gets the ID, and the other service stays 
unversioned. Migration order follows the order of a hash set of processors, so 
it isn't guaranteed to be the same on the author and the consumer.
   
   If the order differs, the consumer's ID is on processor 2's service while 
the author's is on processor 1's. On the next version update, the synchronizer 
points processor 1 at the service holding processor 2's credentials. Processor 
2 gets a new, empty service.
   
   Please change the factory so that it only records what it creates:
   - Replace the `owningGroupIdentity` constructor argument and field (lines 62 
and 69-76) with a `Collection<ControllerServiceNode> createdServices`. The 
4-argument constructor, used by `VersionedFlowSynchronizer` for 
controller-level components, can pass `new ArrayList<>()`.
   - Right after `serviceNode.setProperties(...)` on line 114, add 
`createdServices.add(serviceNode)`. It has to come before line 130, so that a 
service is always in the list before any service its own migration creates.
   - Pass the same `createdServices` into the nested factory on line 129.
   - Delete lines 116-127 and `generateVersionedId` (lines 148-162).



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java:
##########
@@ -433,6 +443,8 @@ public void synchronize(final ProcessGroup group, final 
VersionedExternalFlow ve
                     task.migrateConfiguration(originalPropertyValues, 
serviceFactory);
                 }
             }
+
+            removeMissingControllerServices(group, 
versionedExternalFlow.getFlowContents());

Review Comment:
   [Claude Opus 5.5]
   
   This builds the list of services to remove after property migration, so it 
includes services that migration has just created. The factory enables those 
right away. If one of them isn't referenced through a Controller Service 
property, `removeControllerService` calls `verifyCanDelete`, which throws 
because the service isn't disabled. That fails the whole update after 
everything else has already been applied. At startup, NiFi would not start.
   
   Recording the missing services during the per-group `synchronize`, before 
migration runs, fixes this. It also replaces lines 1207-1321 with much less 
code:
   
   1. Add `private final Set<ControllerServiceNode> missingControllerServices = 
new HashSet<>();` next to `createdAndModifiedExtensions` (line 180), and clear 
it next to `createdAndModifiedExtensions.clear()` (line 351).
   2. In the per-group `synchronize`, right before 
`synchronizeControllerServices(...)` on line 616, add every entry of 
`controllerServicesByVersionedId` whose key isn't the identifier of a service 
in `proposed.getControllerServices()`. The per-group method only runs for 
groups that are being synchronized, so no separate walk over child groups is 
needed.
   3. Replace this line with a call to:
   
   ```java
   private void removeUnreferencedMissingControllerServices() {
       final Set<ComponentNode> keptServices = new HashSet<>();
       boolean serviceKept = true;
       while (serviceKept) {
           serviceKept = false;
           for (final ControllerServiceNode service : 
missingControllerServices) {
               if (!keptServices.contains(service)) {
                   for (final ComponentNode referencingComponent : 
service.getReferences().getReferencingComponents()) {
                       if 
(!missingControllerServices.contains(referencingComponent) || 
keptServices.contains(referencingComponent)) {
                           keptServices.add(service);
                           serviceKept = true;
                           break;
                       }
                   }
               }
           }
       }
   
       for (final ControllerServiceNode service : missingControllerServices) {
           if (keptServices.contains(service)) {
               LOG.info("Keeping {} although the proposed flow does not contain 
it, because it is still referenced by {}", service, 
service.getReferences().getReferencingComponents());
           } else {
               LOG.info("Removing {} from {}", service, 
service.getProcessGroup());
               
context.getControllerServiceProvider().removeControllerService(service);
           }
       }
   }
   ```
   
   This keeps chains of services that something still uses, and removes cycles 
that nothing outside the cycle uses. Removal order doesn't matter, because 
`StandardProcessGroup.removeControllerService` already clears the references 
the removed service held.
   
   4. Delete lines 1207-1321. On line 757, restore the original 
`childCoordinates == null || syncOptions.isUpdateDescendantVersionedFlows()` 
condition, and update the comment on lines 602-603 to say the services are 
recorded here and removed at the end.



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizerTest.java:
##########
@@ -552,6 +571,352 @@ public void testAddProcessorWithServiceAndMigration() {
         assertEquals(controllerServiceNode.getIdentifier(), 
migratedProperties.get("cs"));
     }
 
+    @Nested
+    class MigrationCreatedControllerService {
+
+        private static final String SNAPSHOT_ROOT_ID = "snapshot-root-id";
+        private static final String LOCAL_CHILD_ID = "local-child";
+        private static final String CHILD_GROUP_ID = "child-group-id";
+        private static final String PARENT_GROUP_ID = "parent-group-id";
+        private static final String CHILD_PLACEMENT_ID = "child-placement-id";
+        private static final String DESTINATION_GROUP_ID = 
"destination-group-id";
+
+        private static final String INCOMING_FLOW_ID = "incoming-flow-id";
+        private static final String DESTINATION_FLOW_ID = 
"destination-flow-id";
+        private static final String PARENT_FLOW_ID = "parent-flow-id";
+        private static final String CHILD_FLOW_ID = "child-flow-id";
+
+        private static final String REGISTRY_ID = "registry-id";
+        private static final String SERVICE_TYPE = 
TestControllerService.class.getName();
+        private static final Map<String, String> SERVICE_PROPERTIES = 
Map.of("mode", "read");
+
+        @Test
+        void 
migrationInSynchronizedGroupUsesIncomingFlowIdAfterVersionControlIsCleared() {
+            final ProcessGroup targetGroup = createMockProcessGroup();
+            final VersionControlInformation oldVersionControl = 
mock(VersionControlInformation.class);
+            
when(oldVersionControl.getFlowIdentifier()).thenReturn("old-flow-id");
+
+            final AtomicReference<VersionControlInformation> 
liveVersionControl = new AtomicReference<>(oldVersionControl);
+            
when(targetGroup.getVersionControlInformation()).thenAnswer(invocation -> 
liveVersionControl.get());
+            doAnswer(invocation -> {
+                liveVersionControl.set(null);
+                return null;
+            }).when(targetGroup).disconnectVersionControl();
+
+            final VersionedProcessGroup proposed = new VersionedProcessGroup();
+            proposed.setIdentifier(SNAPSHOT_ROOT_ID);
+            proposed.setProcessors(Set.of(createMinimalVersionedProcessor()));
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposed);
+
+            final VersionedExternalFlowMetadata metadata = new 
VersionedExternalFlowMetadata();
+            metadata.setFlowIdentifier(INCOMING_FLOW_ID);
+            incoming.setMetadata(metadata);
+
+            mockMigratingProcessorIn(targetGroup);
+
+            synchronizer.synchronize(targetGroup, incoming, 
synchronizationOptions);
+
+            assertNull(targetGroup.getVersionControlInformation());
+            final String expectedId = 
expectedMigrationServiceId(INCOMING_FLOW_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void migrationInChildWithoutOwnFlowUsesChildVersionedId() {
+            final ProcessGroup parentGroup = createMockProcessGroup();
+            final ProcessGroup childGroup = createMockProcessGroup();
+            when(childGroup.getIdentifier()).thenReturn(LOCAL_CHILD_ID);
+            when(childGroup.getParent()).thenReturn(parentGroup);
+            
when(childGroup.getVersionedComponentId()).thenReturn(Optional.of(CHILD_GROUP_ID));
+            
when(parentGroup.getProcessGroups()).thenReturn(Set.of(childGroup));
+
+            final VersionedProcessGroup proposedChild = new 
VersionedProcessGroup();
+            proposedChild.setIdentifier(CHILD_GROUP_ID);
+            
proposedChild.setProcessors(Set.of(createMinimalVersionedProcessor()));
+
+            final VersionedProcessGroup proposedParent = new 
VersionedProcessGroup();
+            proposedParent.setIdentifier(PARENT_GROUP_ID);
+            proposedParent.setProcessGroups(Set.of(proposedChild));
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposedParent);
+
+            final VersionedExternalFlowMetadata metadata = new 
VersionedExternalFlowMetadata();
+            metadata.setFlowIdentifier(PARENT_FLOW_ID);
+            incoming.setMetadata(metadata);
+
+            mockMigratingProcessorIn(childGroup);
+
+            synchronizer.synchronize(parentGroup, incoming, 
synchronizationOptions);
+
+            final String expectedId = 
expectedMigrationServiceId(CHILD_GROUP_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void migrationInIndependentlyVersionedChildUsesChildFlowId() {
+            final ProcessGroup parentGroup = createMockProcessGroup();
+            final ProcessGroup childGroup = createMockProcessGroup();
+            when(childGroup.getIdentifier()).thenReturn(LOCAL_CHILD_ID);
+            when(childGroup.getParent()).thenReturn(parentGroup);
+            
when(childGroup.getVersionedComponentId()).thenReturn(Optional.of(CHILD_PLACEMENT_ID));
+            
when(parentGroup.getProcessGroups()).thenReturn(Set.of(childGroup));
+
+            final AtomicReference<VersionControlInformation> 
childVersionControl = new AtomicReference<>();
+            
when(childGroup.getVersionControlInformation()).thenAnswer(invocation -> 
childVersionControl.get());
+            doAnswer(invocation -> {
+                childVersionControl.set(invocation.getArgument(0));
+                return null;
+            }).when(childGroup).setVersionControlInformation(any(), anyMap());
+
+            
when(flowManager.getFlowRegistryClient(REGISTRY_ID)).thenReturn(mock(FlowRegistryClientNode.class));
+            final VersionedFlowCoordinates coordinates = new 
VersionedFlowCoordinates();
+            coordinates.setRegistryId(REGISTRY_ID);
+            coordinates.setBucketId("bucket");
+            coordinates.setFlowId(CHILD_FLOW_ID);
+            coordinates.setVersion("1");
+
+            final VersionedProcessGroup proposedChild = new 
VersionedProcessGroup();
+            proposedChild.setIdentifier(CHILD_PLACEMENT_ID);
+            proposedChild.setVersionedFlowCoordinates(coordinates);
+            
proposedChild.setProcessors(Set.of(createMinimalVersionedProcessor()));
+
+            final VersionedProcessGroup proposedParent = new 
VersionedProcessGroup();
+            proposedParent.setIdentifier(PARENT_GROUP_ID);
+            proposedParent.setProcessGroups(Set.of(proposedChild));
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposedParent);
+
+            final VersionedExternalFlowMetadata metadata = new 
VersionedExternalFlowMetadata();
+            metadata.setFlowIdentifier(PARENT_FLOW_ID);
+            incoming.setMetadata(metadata);
+
+            mockMigratingProcessorIn(childGroup);
+
+            synchronizer.synchronize(parentGroup, incoming, 
synchronizationOptions);
+
+            assertNotNull(childGroup.getVersionControlInformation());
+            assertEquals(CHILD_FLOW_ID, 
childGroup.getVersionControlInformation().getFlowIdentifier());
+            final String expectedId = 
expectedMigrationServiceId(CHILD_FLOW_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void pasteIntoVersionControlledGroupUsesDestinationFlowId() {
+            final ProcessGroup destination = createMockProcessGroup();
+            
when(destination.getVersionedComponentId()).thenReturn(Optional.of(DESTINATION_GROUP_ID));
+
+            final VersionControlInformation versionControl = 
mock(VersionControlInformation.class);
+            
when(versionControl.getFlowIdentifier()).thenReturn(DESTINATION_FLOW_ID);
+            
when(destination.getVersionControlInformation()).thenReturn(versionControl);
+
+            mockMigratingProcessorIn(destination);
+
+            synchronizer.addVersionedComponentsToProcessGroup(destination, 
legacyProcessorAdditions(), synchronizationOptions);
+
+            final String expectedId = 
expectedMigrationServiceId(DESTINATION_FLOW_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void pasteIntoGroupWithoutOwnFlowUsesDestinationVersionedId() {
+            final ProcessGroup destination = createMockProcessGroup();
+            
when(destination.getVersionedComponentId()).thenReturn(Optional.of(DESTINATION_GROUP_ID));
+            mockMigratingProcessorIn(destination);
+
+            synchronizer.addVersionedComponentsToProcessGroup(destination, 
legacyProcessorAdditions(), synchronizationOptions);
+
+            final String expectedId = 
expectedMigrationServiceId(DESTINATION_GROUP_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void pasteWithoutDestinationIdentityLeavesServiceUnversioned() {
+            final ProcessGroup destination = createMockProcessGroup();
+            mockMigratingProcessorIn(destination);
+
+            synchronizer.addVersionedComponentsToProcessGroup(destination, 
legacyProcessorAdditions(), synchronizationOptions);
+
+            verify(destination).addControllerService(controllerServiceNode);
+            verify(controllerServiceNode, 
never()).setVersionedComponentId(any());
+        }
+
+        private void mockMigratingProcessorIn(final ProcessGroup owner) {
+            final ProcessorNode processor = createMockProcessor();
+            
when(processor.getParentProcessGroup()).thenReturn(Optional.of(owner));
+            when(flowManager.createProcessor(any(), any(), any(), 
eq(true))).thenReturn(processor);
+
+            final ControllerServiceCreationDetails details = 
mock(ControllerServiceCreationDetails.class);
+            when(details.serviceIdentifier()).thenReturn("new-service");
+            when(details.type()).thenReturn(SERVICE_TYPE);
+            
when(details.serviceBundleCoordinate()).thenReturn(bundleCoordinate);
+            when(details.serviceProperties()).thenReturn(SERVICE_PROPERTIES);
+            
when(controllerServiceNode.getPropertyDescriptor("mode")).thenReturn(new 
PropertyDescriptor.Builder().name("mode").build());
+
+            doAnswer(invocation -> {
+                final ControllerServiceFactory serviceFactory = 
invocation.getArgument(1);
+                serviceFactory.create(details);
+                return null;
+            }).when(processor).migrateConfiguration(anyMap(), 
any(ControllerServiceFactory.class));
+        }
+
+        private VersionedComponentAdditions legacyProcessorAdditions() {
+            final VersionedProcessor processor = 
createMinimalVersionedProcessor();
+            processor.setGroupIdentifier("source-group-not-destination");
+            return new 
VersionedComponentAdditions.Builder().setProcessors(Set.of(processor)).build();
+        }
+
+        private String expectedMigrationServiceId(final String 
owningGroupIdentity) {
+            final FlowManager expectedFlowManager = mock(FlowManager.class);
+            final ControllerServiceNode expectedService = 
mock(ControllerServiceNode.class);
+            when(expectedService.getPropertyDescriptor("mode")).thenReturn(new 
PropertyDescriptor.Builder().name("mode").build());
+            when(expectedFlowManager.createControllerService(eq(SERVICE_TYPE), 
anyString(), any(), anySet(), eq(true), eq(true), 
eq(null))).thenReturn(expectedService);
+
+            final ComponentNode creator = mock(ComponentNode.class);
+            
when(creator.getParentProcessGroup()).thenReturn(Optional.of(mock(ProcessGroup.class)));
+            final StandardControllerServiceFactory factory = new 
StandardControllerServiceFactory(
+                    mock(ExtensionManager.class), expectedFlowManager, 
mock(ControllerServiceProvider.class), creator, owningGroupIdentity);
+            final ControllerServiceCreationDetails details = 
mock(ControllerServiceCreationDetails.class);
+            when(details.serviceIdentifier()).thenReturn("expected-service");
+            when(details.type()).thenReturn(SERVICE_TYPE);
+            
when(details.serviceBundleCoordinate()).thenReturn(bundleCoordinate);
+            when(details.serviceProperties()).thenReturn(SERVICE_PROPERTIES);
+
+            factory.create(details);
+
+            final ArgumentCaptor<String> versionedId = 
ArgumentCaptor.forClass(String.class);
+            
verify(expectedService).setVersionedComponentId(versionedId.capture());
+            return versionedId.getValue();
+        }
+    }

Review Comment:
   [Claude Opus 5.5]
   
   With the IDs assigned in the synchronizer, please rework these tests around 
the contract: the same inputs give the same ID on every instance, and different 
inputs give different IDs. Compare the IDs captured from 
`setVersionedComponentId` across runs, instead of rebuilding the expected value 
with a second factory. `expectedMigrationServiceId` can then be deleted.
   
   - Keep the synchronized-group test and the two child-group tests. Assert 
that the ID changes when the flow ID or child versioned ID changes, not that it 
equals a value rebuilt here.
   - Keep one paste test. The other two cover the same branches of 
`determineProcessGroupIdentity` as the child-group tests, and the factory no 
longer has a "no identity" case to test.
   - Add one test for migration order, which is the actual bug:
     - Set up two processors in the same group. Each migration creates a 
service of the same type that differs only in a sensitive value.
     - Pass the sensitive value under an old name that the mock service 
reports, after its own migration, under a sensitive descriptor with the current 
name. That is what the AWS and proxy services do.
     - Use a `LinkedHashSet` for `proposed.getProcessors()` so you can run the 
sync once with the processors in one order and once in the other.
     - Assert that each processor's service gets the same ID in both runs and 
that the two IDs differ.
   
     This test fails with the current first-wins rule.



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java:
##########
@@ -308,12 +313,14 @@ public ComponentAdditions 
addVersionedComponentsToProcessGroup(final ProcessGrou
         // update ports to final names
         updatePortsToFinalNames(proposedPortFinalNames);
 
+        final String groupIdentity = determineProcessGroupIdentity(group);
         for (final CreatedOrModifiedExtension createdOrModifiedExtension : 
createdAndModifiedExtensions) {
             final ComponentNode extension = 
createdOrModifiedExtension.extension();
             final Map<String, String> originalPropertyValues = 
createdOrModifiedExtension.propertyValues();
 
+            final String owningGroupIdentity = 
getOwningProcessGroupIdentity(extension, group, groupIdentity);
             final ControllerServiceFactory serviceFactory = new 
StandardControllerServiceFactory(context.getExtensionManager(), 
context.getFlowManager(),
-                    context.getControllerServiceProvider(), extension);
+                    context.getControllerServiceProvider(), extension, 
owningGroupIdentity);
 
             if (extension instanceof final ProcessorNode processor) {
                 processor.migrateConfiguration(originalPropertyValues, 
serviceFactory);

Review Comment:
   [Claude Opus 5.5]
   
   Please replace this loop (lines 316-330) with a call to the shared method 
described in my comment on lines 428-445: 
`migrateCreatedAndModifiedExtensions(group, 
determineProcessGroupIdentity(group))`. A paste has to assign versioned IDs the 
same way as an import or a version change.



##########
nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/migration/MigrationCreatedControllerServiceVersioningIT.java:
##########
@@ -0,0 +1,323 @@
+/*
+ * 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.migration;
+
+import org.apache.nifi.tests.system.AbstractNarSwapMigrationIT;
+import org.apache.nifi.toolkit.client.NiFiClientException;
+import org.apache.nifi.web.api.dto.ComponentStateDTO;
+import org.apache.nifi.web.api.dto.ProcessorDTO;
+import org.apache.nifi.web.api.dto.StateEntryDTO;
+import org.apache.nifi.web.api.entity.ComponentStateEntity;
+import org.apache.nifi.web.api.entity.ControllerServiceEntity;
+import org.apache.nifi.web.api.entity.FlowRegistryClientEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupEntity;
+import org.apache.nifi.web.api.entity.ProcessorEntity;
+import org.apache.nifi.web.api.entity.VersionedFlowUpdateRequestEntity;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies that startup and import migration create matching Controller 
Service identities and that
+ * flow version changes preserve the service instance, references, and state.
+ */
+class MigrationCreatedControllerServiceVersioningIT extends 
AbstractNarSwapMigrationIT {
+
+    private static final String TEST_FLOWS_BUCKET = "test-flows";
+    private static final String FLOW_ID = 
"0831859c-f4a1-4ff6-99b3-58aeb35cb17f";
+    private static final String FLOW_DEFINITION_FILENAME = "snapshot.json";
+    private static final String VERSION_1 = "1";
+    private static final String VERSION_2 = "2";
+    private static final String VERSION_3 = "3";
+    private static final Path FLOW_SNAPSHOT = 
Path.of("src/test/resources/versioned-flows", TEST_FLOWS_BUCKET, FLOW_ID, 
VERSION_1, FLOW_DEFINITION_FILENAME);
+
+    private static final String STORE_SERVICE_PROPERTY = "Store Service";
+    private static final String STORE_SERVICE_TYPE = TEST_CS_PACKAGE + 
".StateBackedStoreService";
+
+    private static final String MIGRATING_PROCESSOR_NAME = 
"MigrateToControllerService";
+    private static final String PROCESSOR_TYPE = TEST_PROCESSORS_PACKAGE + "." 
+ MIGRATING_PROCESSOR_NAME;
+    private static final String ADDED_PROCESSOR_NAME = "Added Processor";
+
+    private static final String ROOT_PG = "root";
+
+    private static final String CREATED_STATE_KEY = "created";
+    private static final String ROW_COUNT_STATE_KEY = "rowCount";
+    private static final long CONDITION_POLL_MILLIS = 100L;
+
+    @TempDir
+    Path registryDirectory;
+
+    @Test
+    void testStartupAndImportMigratedServicesSurviveFlowVersionUpdates() 
throws NiFiClientException, IOException, InterruptedException {

Review Comment:
   [Claude Opus 5.5]
   
   This test covers the right paths: startup and import migration, then updates 
with and without the service. But it can't catch either problem described in 
the review:
   - The migration passes one value that isn't sensitive and keeps its name, so 
every instance hashes the same input.
   - There is only one service per group (the processor added in V2 has no 
`Store Name`, so it creates nothing), so two services never compete for an ID.
   
   Please strengthen this test rather than adding a new one:
   
   1. **Add a secret that the service renames, like the AWS processors do.**
      - Pre-upgrade `MigrateToControllerService`: add a required, sensitive 
`Store Password`.
      - Post-upgrade `MigrateToControllerService`: pass it as 
`"store-password"`.
      - `StateBackedStoreService`: declare a required, sensitive `Store 
Password` and rename `store-password` to it in `migrateProperties`.
      - `snapshot.json`: add the descriptor with no value.
      - Before the upgrade, set different passwords on the author and on the 
startup consumer. Set the import consumer's password on its service after 
import.
   
      Because the property is required, the existing "enabled" checks also show 
that each secret survives the update. With the current code this fails on line 
111.
   
   2. **Add a second migrating processor with the same `Store Name` and a 
different password.**
      - Replace the "exactly one service" checks (`waitForSingleStoreService` 
and line 220) with checks for each processor: the same service instance ID and 
`created` timestamp before and after each update.
      - Check that the two services have different versioned IDs.
      - Check that each consumer processor's service has the same versioned ID 
as the author's service for the same processor.
   
      This shows that each processor keeps its own service and secret.



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizerTest.java:
##########
@@ -552,6 +571,352 @@ public void testAddProcessorWithServiceAndMigration() {
         assertEquals(controllerServiceNode.getIdentifier(), 
migratedProperties.get("cs"));
     }
 
+    @Nested
+    class MigrationCreatedControllerService {
+
+        private static final String SNAPSHOT_ROOT_ID = "snapshot-root-id";
+        private static final String LOCAL_CHILD_ID = "local-child";
+        private static final String CHILD_GROUP_ID = "child-group-id";
+        private static final String PARENT_GROUP_ID = "parent-group-id";
+        private static final String CHILD_PLACEMENT_ID = "child-placement-id";
+        private static final String DESTINATION_GROUP_ID = 
"destination-group-id";
+
+        private static final String INCOMING_FLOW_ID = "incoming-flow-id";
+        private static final String DESTINATION_FLOW_ID = 
"destination-flow-id";
+        private static final String PARENT_FLOW_ID = "parent-flow-id";
+        private static final String CHILD_FLOW_ID = "child-flow-id";
+
+        private static final String REGISTRY_ID = "registry-id";
+        private static final String SERVICE_TYPE = 
TestControllerService.class.getName();
+        private static final Map<String, String> SERVICE_PROPERTIES = 
Map.of("mode", "read");
+
+        @Test
+        void 
migrationInSynchronizedGroupUsesIncomingFlowIdAfterVersionControlIsCleared() {
+            final ProcessGroup targetGroup = createMockProcessGroup();
+            final VersionControlInformation oldVersionControl = 
mock(VersionControlInformation.class);
+            
when(oldVersionControl.getFlowIdentifier()).thenReturn("old-flow-id");
+
+            final AtomicReference<VersionControlInformation> 
liveVersionControl = new AtomicReference<>(oldVersionControl);
+            
when(targetGroup.getVersionControlInformation()).thenAnswer(invocation -> 
liveVersionControl.get());
+            doAnswer(invocation -> {
+                liveVersionControl.set(null);
+                return null;
+            }).when(targetGroup).disconnectVersionControl();
+
+            final VersionedProcessGroup proposed = new VersionedProcessGroup();
+            proposed.setIdentifier(SNAPSHOT_ROOT_ID);
+            proposed.setProcessors(Set.of(createMinimalVersionedProcessor()));
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposed);
+
+            final VersionedExternalFlowMetadata metadata = new 
VersionedExternalFlowMetadata();
+            metadata.setFlowIdentifier(INCOMING_FLOW_ID);
+            incoming.setMetadata(metadata);
+
+            mockMigratingProcessorIn(targetGroup);
+
+            synchronizer.synchronize(targetGroup, incoming, 
synchronizationOptions);
+
+            assertNull(targetGroup.getVersionControlInformation());
+            final String expectedId = 
expectedMigrationServiceId(INCOMING_FLOW_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void migrationInChildWithoutOwnFlowUsesChildVersionedId() {
+            final ProcessGroup parentGroup = createMockProcessGroup();
+            final ProcessGroup childGroup = createMockProcessGroup();
+            when(childGroup.getIdentifier()).thenReturn(LOCAL_CHILD_ID);
+            when(childGroup.getParent()).thenReturn(parentGroup);
+            
when(childGroup.getVersionedComponentId()).thenReturn(Optional.of(CHILD_GROUP_ID));
+            
when(parentGroup.getProcessGroups()).thenReturn(Set.of(childGroup));
+
+            final VersionedProcessGroup proposedChild = new 
VersionedProcessGroup();
+            proposedChild.setIdentifier(CHILD_GROUP_ID);
+            
proposedChild.setProcessors(Set.of(createMinimalVersionedProcessor()));
+
+            final VersionedProcessGroup proposedParent = new 
VersionedProcessGroup();
+            proposedParent.setIdentifier(PARENT_GROUP_ID);
+            proposedParent.setProcessGroups(Set.of(proposedChild));
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposedParent);
+
+            final VersionedExternalFlowMetadata metadata = new 
VersionedExternalFlowMetadata();
+            metadata.setFlowIdentifier(PARENT_FLOW_ID);
+            incoming.setMetadata(metadata);
+
+            mockMigratingProcessorIn(childGroup);
+
+            synchronizer.synchronize(parentGroup, incoming, 
synchronizationOptions);
+
+            final String expectedId = 
expectedMigrationServiceId(CHILD_GROUP_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void migrationInIndependentlyVersionedChildUsesChildFlowId() {
+            final ProcessGroup parentGroup = createMockProcessGroup();
+            final ProcessGroup childGroup = createMockProcessGroup();
+            when(childGroup.getIdentifier()).thenReturn(LOCAL_CHILD_ID);
+            when(childGroup.getParent()).thenReturn(parentGroup);
+            
when(childGroup.getVersionedComponentId()).thenReturn(Optional.of(CHILD_PLACEMENT_ID));
+            
when(parentGroup.getProcessGroups()).thenReturn(Set.of(childGroup));
+
+            final AtomicReference<VersionControlInformation> 
childVersionControl = new AtomicReference<>();
+            
when(childGroup.getVersionControlInformation()).thenAnswer(invocation -> 
childVersionControl.get());
+            doAnswer(invocation -> {
+                childVersionControl.set(invocation.getArgument(0));
+                return null;
+            }).when(childGroup).setVersionControlInformation(any(), anyMap());
+
+            
when(flowManager.getFlowRegistryClient(REGISTRY_ID)).thenReturn(mock(FlowRegistryClientNode.class));
+            final VersionedFlowCoordinates coordinates = new 
VersionedFlowCoordinates();
+            coordinates.setRegistryId(REGISTRY_ID);
+            coordinates.setBucketId("bucket");
+            coordinates.setFlowId(CHILD_FLOW_ID);
+            coordinates.setVersion("1");
+
+            final VersionedProcessGroup proposedChild = new 
VersionedProcessGroup();
+            proposedChild.setIdentifier(CHILD_PLACEMENT_ID);
+            proposedChild.setVersionedFlowCoordinates(coordinates);
+            
proposedChild.setProcessors(Set.of(createMinimalVersionedProcessor()));
+
+            final VersionedProcessGroup proposedParent = new 
VersionedProcessGroup();
+            proposedParent.setIdentifier(PARENT_GROUP_ID);
+            proposedParent.setProcessGroups(Set.of(proposedChild));
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposedParent);
+
+            final VersionedExternalFlowMetadata metadata = new 
VersionedExternalFlowMetadata();
+            metadata.setFlowIdentifier(PARENT_FLOW_ID);
+            incoming.setMetadata(metadata);
+
+            mockMigratingProcessorIn(childGroup);
+
+            synchronizer.synchronize(parentGroup, incoming, 
synchronizationOptions);
+
+            assertNotNull(childGroup.getVersionControlInformation());
+            assertEquals(CHILD_FLOW_ID, 
childGroup.getVersionControlInformation().getFlowIdentifier());
+            final String expectedId = 
expectedMigrationServiceId(CHILD_FLOW_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void pasteIntoVersionControlledGroupUsesDestinationFlowId() {
+            final ProcessGroup destination = createMockProcessGroup();
+            
when(destination.getVersionedComponentId()).thenReturn(Optional.of(DESTINATION_GROUP_ID));
+
+            final VersionControlInformation versionControl = 
mock(VersionControlInformation.class);
+            
when(versionControl.getFlowIdentifier()).thenReturn(DESTINATION_FLOW_ID);
+            
when(destination.getVersionControlInformation()).thenReturn(versionControl);
+
+            mockMigratingProcessorIn(destination);
+
+            synchronizer.addVersionedComponentsToProcessGroup(destination, 
legacyProcessorAdditions(), synchronizationOptions);
+
+            final String expectedId = 
expectedMigrationServiceId(DESTINATION_FLOW_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void pasteIntoGroupWithoutOwnFlowUsesDestinationVersionedId() {
+            final ProcessGroup destination = createMockProcessGroup();
+            
when(destination.getVersionedComponentId()).thenReturn(Optional.of(DESTINATION_GROUP_ID));
+            mockMigratingProcessorIn(destination);
+
+            synchronizer.addVersionedComponentsToProcessGroup(destination, 
legacyProcessorAdditions(), synchronizationOptions);
+
+            final String expectedId = 
expectedMigrationServiceId(DESTINATION_GROUP_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void pasteWithoutDestinationIdentityLeavesServiceUnversioned() {
+            final ProcessGroup destination = createMockProcessGroup();
+            mockMigratingProcessorIn(destination);
+
+            synchronizer.addVersionedComponentsToProcessGroup(destination, 
legacyProcessorAdditions(), synchronizationOptions);
+
+            verify(destination).addControllerService(controllerServiceNode);
+            verify(controllerServiceNode, 
never()).setVersionedComponentId(any());
+        }
+
+        private void mockMigratingProcessorIn(final ProcessGroup owner) {
+            final ProcessorNode processor = createMockProcessor();
+            
when(processor.getParentProcessGroup()).thenReturn(Optional.of(owner));
+            when(flowManager.createProcessor(any(), any(), any(), 
eq(true))).thenReturn(processor);
+
+            final ControllerServiceCreationDetails details = 
mock(ControllerServiceCreationDetails.class);
+            when(details.serviceIdentifier()).thenReturn("new-service");
+            when(details.type()).thenReturn(SERVICE_TYPE);
+            
when(details.serviceBundleCoordinate()).thenReturn(bundleCoordinate);
+            when(details.serviceProperties()).thenReturn(SERVICE_PROPERTIES);
+            
when(controllerServiceNode.getPropertyDescriptor("mode")).thenReturn(new 
PropertyDescriptor.Builder().name("mode").build());
+
+            doAnswer(invocation -> {
+                final ControllerServiceFactory serviceFactory = 
invocation.getArgument(1);
+                serviceFactory.create(details);
+                return null;
+            }).when(processor).migrateConfiguration(anyMap(), 
any(ControllerServiceFactory.class));
+        }
+
+        private VersionedComponentAdditions legacyProcessorAdditions() {
+            final VersionedProcessor processor = 
createMinimalVersionedProcessor();
+            processor.setGroupIdentifier("source-group-not-destination");
+            return new 
VersionedComponentAdditions.Builder().setProcessors(Set.of(processor)).build();
+        }
+
+        private String expectedMigrationServiceId(final String 
owningGroupIdentity) {
+            final FlowManager expectedFlowManager = mock(FlowManager.class);
+            final ControllerServiceNode expectedService = 
mock(ControllerServiceNode.class);
+            when(expectedService.getPropertyDescriptor("mode")).thenReturn(new 
PropertyDescriptor.Builder().name("mode").build());
+            when(expectedFlowManager.createControllerService(eq(SERVICE_TYPE), 
anyString(), any(), anySet(), eq(true), eq(true), 
eq(null))).thenReturn(expectedService);
+
+            final ComponentNode creator = mock(ComponentNode.class);
+            
when(creator.getParentProcessGroup()).thenReturn(Optional.of(mock(ProcessGroup.class)));
+            final StandardControllerServiceFactory factory = new 
StandardControllerServiceFactory(
+                    mock(ExtensionManager.class), expectedFlowManager, 
mock(ControllerServiceProvider.class), creator, owningGroupIdentity);
+            final ControllerServiceCreationDetails details = 
mock(ControllerServiceCreationDetails.class);
+            when(details.serviceIdentifier()).thenReturn("expected-service");
+            when(details.type()).thenReturn(SERVICE_TYPE);
+            
when(details.serviceBundleCoordinate()).thenReturn(bundleCoordinate);
+            when(details.serviceProperties()).thenReturn(SERVICE_PROPERTIES);
+
+            factory.create(details);
+
+            final ArgumentCaptor<String> versionedId = 
ArgumentCaptor.forClass(String.class);
+            
verify(expectedService).setVersionedComponentId(versionedId.capture());
+            return versionedId.getValue();
+        }
+    }
+
+    @Nested
+    class MissingControllerServiceRemoval {
+
+        private static final String ROOT_GROUP_ID = "root-group-id";
+        private static final String PARENT_GROUP_ID = "parent-group-id";
+        private static final String CHILD_GROUP_ID = "child-group-id";
+        private static final String VERSIONED_CHILD_GROUP_ID = 
"versioned-child-group-id";
+
+        @Test
+        void 
synchronizationKeepsMissingServicesReachableFromRemainingProcessor() {
+            final ProcessGroup targetGroup = createMockProcessGroup();
+            final ControllerServiceNode directlyReferenced = 
createMockControllerService();
+            final ControllerServiceNode referencedByDirect = 
createMockControllerService();
+            final ControllerServiceNode referencedByIndirect = 
createMockControllerService();
+            setReferences(directlyReferenced, createMockProcessor());
+            setReferences(referencedByDirect, directlyReferenced);
+            setReferences(referencedByIndirect, referencedByDirect);
+            
when(targetGroup.getControllerServices(false)).thenReturn(Set.of(directlyReferenced,
 referencedByDirect, referencedByIndirect));
+
+            synchronizer.synchronize(targetGroup, 
incomingFlowWithoutServices(), synchronizationOptions);
+
+            verify(controllerServiceProvider, 
never()).removeControllerService(directlyReferenced);
+            verify(controllerServiceProvider, 
never()).removeControllerService(referencedByDirect);
+            verify(controllerServiceProvider, 
never()).removeControllerService(referencedByIndirect);
+        }
+
+        @Test
+        void 
synchronizationRemovesMissingServicesReferencedOnlyByOtherMissingServices() {
+            final ProcessGroup targetGroup = createMockProcessGroup();
+            final ControllerServiceNode cycleFirst = 
createMockControllerService();
+            final ControllerServiceNode cycleSecond = 
createMockControllerService();
+            final ControllerServiceNode unreferenced = 
createMockControllerService();
+            setReferences(cycleFirst, cycleSecond);
+            setReferences(cycleSecond, cycleFirst);
+            
when(targetGroup.getControllerServices(false)).thenReturn(Set.of(cycleFirst, 
cycleSecond, unreferenced));
+
+            synchronizer.synchronize(targetGroup, 
incomingFlowWithoutServices(), synchronizationOptions);
+
+            
verify(controllerServiceProvider).removeControllerService(cycleFirst);
+            
verify(controllerServiceProvider).removeControllerService(cycleSecond);
+            
verify(controllerServiceProvider).removeControllerService(unreferenced);
+        }
+
+        @Test
+        void 
synchronizationKeepsMissingServiceReferencedAfterPropertyMigration() {
+            final ProcessGroup targetGroup = createMockProcessGroup();
+            final ControllerServiceNode service = 
createMockControllerService();
+            
when(targetGroup.getControllerServices(false)).thenReturn(Set.of(service));
+
+            final ProcessorNode processor = createMockProcessor();
+            
when(processor.getParentProcessGroup()).thenReturn(Optional.of(targetGroup));
+            when(flowManager.createProcessor(any(), any(), any(), 
eq(true))).thenReturn(processor);
+            doAnswer(invocation -> 
service.getReferences().getReferencingComponents().add(processor))
+                .when(processor).migrateConfiguration(anyMap(), 
any(ControllerServiceFactory.class));
+
+            final VersionedProcessGroup proposed = new VersionedProcessGroup();
+            proposed.setIdentifier(ROOT_GROUP_ID);
+            proposed.setProcessors(Set.of(createMinimalVersionedProcessor()));
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposed);
+
+            synchronizer.synchronize(targetGroup, incoming, 
synchronizationOptions);
+
+            verify(controllerServiceProvider, 
never()).removeControllerService(service);
+        }
+
+        @Test
+        void 
synchronizationRemovesMissingServicesOnlyInSynchronizedChildGroups() {
+            final ProcessGroup parentGroup = createMockProcessGroup();
+            final ProcessGroup childGroup = createMockProcessGroup();
+            
when(childGroup.getVersionedComponentId()).thenReturn(Optional.of(CHILD_GROUP_ID));
+            final ProcessGroup versionedChildGroup = createMockProcessGroup();
+            
when(versionedChildGroup.getVersionedComponentId()).thenReturn(Optional.of(VERSIONED_CHILD_GROUP_ID));
+            when(parentGroup.getProcessGroups()).thenReturn(Set.of(childGroup, 
versionedChildGroup));
+
+            final ControllerServiceNode childService = 
createMockControllerService();
+            
when(childGroup.getControllerServices(false)).thenReturn(Set.of(childService));
+            final ControllerServiceNode versionedChildService = 
createMockControllerService();
+            
when(versionedChildGroup.getControllerServices(false)).thenReturn(Set.of(versionedChildService));
+
+            final ProcessorNode versionedChildProcessor = 
createMappableProcessor(versionedChildGroup);
+            
when(versionedChildGroup.getProcessors()).thenReturn(Set.of(versionedChildProcessor));
+            final ControllerServiceNode parentService = 
createMockControllerService();
+            setReferences(parentService, versionedChildProcessor);
+            
when(parentGroup.getControllerServices(false)).thenReturn(Set.of(parentService));
+
+            final VersionedFlowCoordinates coordinates = new 
VersionedFlowCoordinates();
+            coordinates.setRegistryId("registry-id");
+            coordinates.setBucketId("bucket");
+            coordinates.setFlowId("child-flow-id");
+            coordinates.setVersion("1");
+
+            final VersionedProcessGroup proposedChild = new 
VersionedProcessGroup();
+            proposedChild.setIdentifier(CHILD_GROUP_ID);
+            final VersionedProcessGroup proposedVersionedChild = new 
VersionedProcessGroup();
+            proposedVersionedChild.setIdentifier(VERSIONED_CHILD_GROUP_ID);
+            proposedVersionedChild.setVersionedFlowCoordinates(coordinates);
+            final VersionedProcessGroup proposedParent = new 
VersionedProcessGroup();
+            proposedParent.setIdentifier(PARENT_GROUP_ID);
+            proposedParent.setProcessGroups(Set.of(proposedChild, 
proposedVersionedChild));
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposedParent);
+
+            final FlowSynchronizationOptions parentOnlyOptions = 
FlowSynchronizationOptions.Builder.from(synchronizationOptions)
+                .updateDescendantVersionedFlows(false)
+                .build();
+
+            synchronizer.synchronize(parentGroup, incoming, parentOnlyOptions);
+
+            
verify(controllerServiceProvider).removeControllerService(childService);
+            verify(controllerServiceProvider, 
never()).removeControllerService(versionedChildService);
+            verify(controllerServiceProvider, 
never()).removeControllerService(parentService);
+        }
+
+        private VersionedExternalFlow incomingFlowWithoutServices() {
+            final VersionedProcessGroup proposed = new VersionedProcessGroup();
+            proposed.setIdentifier(ROOT_GROUP_ID);
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposed);
+            return incoming;
+        }
+    }

Review Comment:
   [Claude Opus 5.5]
   
   These can come down to two tests:
   
   - Fold the chain case (line 805) and the cycle case (line 823) into the 
existing `testControllerServiceRemoved` (line 1844), which this PR already 
strengthens.
     - Its "unreferenced service is removed" case duplicates the `unreferenced` 
service in the cycle test, so drop one of them.
     - Its last `verify` (line 1878) repeats line 1856.
   - Keep `synchronizationKeepsMissingServiceReferencedAfterPropertyMigration`, 
which is the real regression test for the Jira. Strengthen it with a second 
service that migration creates and nothing references, and verify that it isn't 
removed. That covers the failure described in my comment on line 447 of the 
synchronizer.
   - `synchronizationRemovesMissingServicesOnlyInSynchronizedChildGroups` can 
be removed. Once missing services are recorded in the per-group `synchronize`, 
it only checks behavior that existed before this PR.



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java:
##########
@@ -418,12 +425,15 @@ public void synchronize(final ProcessGroup group, final 
VersionedExternalFlow ve
                 }
             });
 
+            final VersionedExternalFlowMetadata metadata = 
versionedExternalFlow.getMetadata();
+            final String flowIdentifier = metadata == null ? null : 
metadata.getFlowIdentifier();
             for (final CreatedOrModifiedExtension createdOrModifiedExtension : 
createdAndModifiedExtensions) {
                 final ComponentNode extension = 
createdOrModifiedExtension.extension();
                 final Map<String, String> originalPropertyValues = 
createdOrModifiedExtension.propertyValues();
 
+                final String owningGroupIdentity = 
getOwningProcessGroupIdentity(extension, group, flowIdentifier);
                 final ControllerServiceFactory serviceFactory = new 
StandardControllerServiceFactory(context.getExtensionManager(), 
context.getFlowManager(),
-                    context.getControllerServiceProvider(), extension);
+                    context.getControllerServiceProvider(), extension, 
owningGroupIdentity);
 
                 if (extension instanceof final ProcessorNode processor) {
                     processor.migrateConfiguration(originalPropertyValues, 
serviceFactory);

Review Comment:
   [Claude Opus 5.5]
   
   This is where the versioned IDs should be assigned, after every migration in 
the update has finished.
   
   Please replace this loop (lines 428-445) and the near-copy in 
`addVersionedComponentsToProcessGroup` (lines 316-330) with one private method 
that both call. `synchronize` would call it with `group` and the metadata 
`flowIdentifier`. `addVersionedComponentsToProcessGroup` would call it with 
`group` and `determineProcessGroupIdentity(group)`. Roughly:
   
   ```java
   private void migrateCreatedAndModifiedExtensions(final ProcessGroup 
synchronizedGroup, final String synchronizedGroupIdentity) {
       final List<ControllerServiceNode> createdServices = new ArrayList<>();
       for (final CreatedOrModifiedExtension createdOrModifiedExtension : 
createdAndModifiedExtensions) {
           final ComponentNode extension = 
createdOrModifiedExtension.extension();
           final Map<String, String> originalPropertyValues = 
createdOrModifiedExtension.propertyValues();
           final ControllerServiceFactory serviceFactory = new 
StandardControllerServiceFactory(context.getExtensionManager(), 
context.getFlowManager(),
               context.getControllerServiceProvider(), extension, 
createdServices);
   
           if (extension instanceof final ProcessorNode processor) {
               processor.migrateConfiguration(originalPropertyValues, 
serviceFactory);
           } else if (extension instanceof final ControllerServiceNode service) 
{
               service.migrateConfiguration(originalPropertyValues, 
serviceFactory);
           } else if (extension instanceof final ReportingTaskNode task) {
               task.migrateConfiguration(originalPropertyValues, 
serviceFactory);
           }
       }
   
       // A service is added to the list before any service that its own 
migration creates, so the referencing service has its versioned ID first
       for (final ControllerServiceNode service : createdServices) {
           assignVersionedComponentId(service, synchronizedGroup, 
synchronizedGroupIdentity);
       }
   }
   ```
   
   `assignVersionedComponentId` builds the ID from the following parts. Each 
one is the same on every instance and doesn't depend on migration order.
   
   1. `getOwningProcessGroupIdentity(service, synchronizedGroup, 
synchronizedGroupIdentity)`. If it is null, leave the service unversioned, as 
now.
   2. `service.getCanonicalClassName()`.
   3. A `TreeMap` of property name to value from 
`service.getRawPropertyValues()`. Look up each descriptor with 
`service.getPropertyDescriptor(name)` so that sensitive dynamic properties are 
recognized. Skip it if it is sensitive or if `getControllerServiceDefinition() 
!= null`. Instance IDs of other services differ between instances, and the 
reference is captured by step 4 of the referenced service anyway.
   4. The sorted versioned IDs of 
`service.getReferences().getReferencingComponents()`. Use a `switch` on 
`ProcessorNode` and `ControllerServiceNode` to call 
`getVersionedComponentId()`. Avoid an `instanceof VersionedComponent` check, 
because `org.apache.nifi.flow.VersionedComponent` is already imported in this 
class. If any referencing component has no versioned ID, leave the service 
unversioned.
   
   Hash the four parts with `UUID.nameUUIDFromBytes`, the same way as today. If 
that ID is already used by another service in `service.getProcessGroup()`, add 
a counter (1, 2, ...) to the hashed text until it's unused. That can only 
happen when a single component's migration creates two services of the same 
type that differ only in secret values. The order within one migration is fixed 
by its code, so the counter gives the same result on every instance.
   
   With this in place, the `occupied` check in the factory goes away, along 
with the extra constructor argument.



##########
nifi-system-tests/nifi-alternate-config-extensions-bundle/nifi-alternate-config-extensions/src/main/java/org/apache/nifi/processors/tests/system/MigrateToControllerService.java:
##########
@@ -0,0 +1,90 @@
+/*
+ * 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.processors.tests.system;
+
+import org.apache.nifi.annotation.behavior.InputRequirement;
+import org.apache.nifi.annotation.behavior.InputRequirement.Requirement;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.cs.tests.system.StateBackedStoreService;
+import org.apache.nifi.cs.tests.system.StoreService;
+import org.apache.nifi.migration.PropertyConfiguration;
+import org.apache.nifi.processor.AbstractProcessor;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.Relationship;
+import org.apache.nifi.processor.exception.ProcessException;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+
+@CapabilityDescription("""
+        Post-upgrade shape of a processor whose property migration creates the 
store Controller Service that the pre-upgrade shape did not have.
+        Each execution appends a row to the store so that tests can observe 
whether store contents survive flow and runtime upgrades.
+        """)
+@InputRequirement(Requirement.INPUT_FORBIDDEN)
+public class MigrateToControllerService extends AbstractProcessor {
+
+    static final PropertyDescriptor STORE_SERVICE = new 
PropertyDescriptor.Builder()
+            .name("Store Service")
+            .required(false)
+            .identifiesControllerService(StoreService.class)
+            .build();
+
+    static final Relationship REL_SUCCESS = new 
Relationship.Builder().name("success").build();
+
+    private static final List<PropertyDescriptor> PROPERTIES = 
List.of(STORE_SERVICE);
+    private static final Set<Relationship> RELATIONSHIPS = Set.of(REL_SUCCESS);
+
+    private final AtomicLong rowCounter = new AtomicLong(0L);
+
+    @Override
+    protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTIES;
+    }
+
+    @Override
+    public Set<Relationship> getRelationships() {
+        return RELATIONSHIPS;
+    }
+
+    @Override
+    public void migrateProperties(final PropertyConfiguration config) {
+        final String storeName = config.getPropertyValue("Store 
Name").orElse(null);
+        config.removeProperty("Store Name");
+
+        if (storeName != null) {
+            final String serviceId = 
config.createControllerService(StateBackedStoreService.class.getName(), 
Map.of("Store Name", storeName));
+            config.setProperty(STORE_SERVICE, serviceId);
+        }
+    }
+
+    @Override
+    public void onTrigger(final ProcessContext context, final ProcessSession 
session) throws ProcessException {
+        final StoreService storeService = 
context.getProperty(STORE_SERVICE).asControllerService(StoreService.class);
+        if (storeService == null) {
+            context.yield();
+            return;
+        }
+
+        storeService.append("row-" + rowCounter.getAndIncrement());

Review Comment:
   [Claude Opus 5.5]
   
   This processor has no run schedule, so it writes the service's local state 
on every run, nonstop, for the whole test. The `created` value the service 
writes when it's first enabled already proves the service wasn't recreated. 
Please drop the row counting:
   - `onTrigger` can do nothing.
   - Remove `append` from `StoreService` and `StateBackedStoreService`, along 
with the `rowCount` and `lastRow` keys.
   - Remove `countRows` and the waits on lines 207 and 235-236 of 
`MigrationCreatedControllerServiceVersioningIT`.
   - The "still present" check in `readCreatedTimestamp` can also go, because 
both callers have just confirmed the service exists.



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/migration/TestStandardControllerServiceFactory.java:
##########
@@ -142,6 +155,144 @@ public void testServiceIdDeterministic() {
         assertEquals(4, Set.of(initialServiceId, secondGroupId, thirdId, 
fourthId).size());
     }
 
+    @Nested
+    class VersionedIdAssignment {
+        private static final String ENDPOINT_PROPERTY = "Endpoint";
+        private static final String ENDPOINT_PARAMETER = "#{endpoint}";
+        private static final String MODE_PROPERTY = "Mode";
+        private static final String READ_MODE = "read";
+        private static final String PASSWORD_PROPERTY = "Password";
+
+        private static final String OWNING_GROUP_IDENTITY = "shared-version";
+
+        private static final String CONSUMER_GROUP_ID = "consumer-instance";
+
+        @Test
+        void testVersionedIdentityDependsOnSuppliedIdentityTypeAndProperties() 
{
+            final Map<String, String> properties = new LinkedHashMap<>();
+            properties.put(ENDPOINT_PROPERTY, ENDPOINT_PARAMETER);
+            properties.put(MODE_PROPERTY, READ_MODE);
+            final String first = 
createServiceAndGetVersionedId("author-instance", OWNING_GROUP_IDENTITY, 
IMPL_CLASS, properties);
+
+            final Map<String, String> reordered = new LinkedHashMap<>();
+            reordered.put(MODE_PROPERTY, READ_MODE);
+            reordered.put(ENDPOINT_PROPERTY, ENDPOINT_PARAMETER);
+            final String second = 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, 
IMPL_CLASS, reordered);
+
+            assertNotNull(first);
+            assertEquals(first, second);
+            assertNotEquals(first, 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, "different-version", 
IMPL_CLASS, properties));
+            assertNotEquals(first, 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, 
"AnotherService", properties));
+            assertNotEquals(first, 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, 
IMPL_CLASS, Map.of()));
+        }
+
+        @Test
+        void testSensitiveValuesDoNotAffectVersionedIdentity() {
+            final Map<String, String> authorProperties = 
Map.of(ENDPOINT_PROPERTY, ENDPOINT_PARAMETER, PASSWORD_PROPERTY, 
"author-password");
+            final Map<String, String> consumerProperties = 
Map.of(ENDPOINT_PROPERTY, ENDPOINT_PARAMETER, PASSWORD_PROPERTY, 
"consumer-password");
+            final String authorId = 
createServiceAndGetVersionedId("author-instance", OWNING_GROUP_IDENTITY, 
IMPL_CLASS, authorProperties);
+            final String consumerId = 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, 
IMPL_CLASS, consumerProperties);
+
+            assertEquals(authorId, consumerId);
+            assertEquals(authorId, 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, 
IMPL_CLASS, Map.of(ENDPOINT_PROPERTY, ENDPOINT_PARAMETER)));
+            assertNotEquals(authorId, 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, 
IMPL_CLASS,
+                    Map.of(ENDPOINT_PROPERTY, "another-endpoint", 
PASSWORD_PROPERTY, "consumer-password")));
+            assertNotEquals(factory.determineServiceId(IMPL_CLASS, 
authorProperties), factory.determineServiceId(IMPL_CLASS, consumerProperties));
+        }
+
+        @Test
+        void testOccupiedVersionedIdentityLeavesNewServiceUnversioned() {
+            final Map<String, String> properties = Map.of(ENDPOINT_PROPERTY, 
ENDPOINT_PARAMETER);
+            final String occupiedId = 
createServiceAndGetVersionedId("author-instance", OWNING_GROUP_IDENTITY, 
IMPL_CLASS, properties);
+            final ControllerServiceNode existingService = 
mock(ControllerServiceNode.class);
+            
when(existingService.getVersionedComponentId()).thenReturn(Optional.of(occupiedId));
+
+            final ControllerServiceNode newService = 
createService(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, IMPL_CLASS, properties, 
Set.of(existingService));
+
+            verify(newService, never()).setVersionedComponentId(any());
+            verify(existingService, never()).setVersionedComponentId(any());
+            verify(newService).migrateConfiguration(eq(properties), any());
+        }
+
+        @Test
+        void testUnversionedAndDifferentVersionedServicesDoNotOccupyIdentity() 
{
+            final Map<String, String> properties = Map.of(ENDPOINT_PROPERTY, 
ENDPOINT_PARAMETER);
+            final String expectedId = 
createServiceAndGetVersionedId("author-instance", OWNING_GROUP_IDENTITY, 
IMPL_CLASS, properties);
+            final ControllerServiceNode unversionedService = 
mock(ControllerServiceNode.class);
+            
when(unversionedService.getVersionedComponentId()).thenReturn(Optional.empty());
+            final ControllerServiceNode differentlyVersionedService = 
mock(ControllerServiceNode.class);
+            
when(differentlyVersionedService.getVersionedComponentId()).thenReturn(Optional.of("another-versioned-id"));
+
+            final ControllerServiceNode newService = 
createService(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, IMPL_CLASS, properties,
+                    Set.of(unversionedService, differentlyVersionedService));
+
+            verify(newService).setVersionedComponentId(expectedId);
+            verify(unversionedService, never()).setVersionedComponentId(any());
+            verify(differentlyVersionedService, 
never()).setVersionedComponentId(any());
+        }
+
+        @Test
+        void testCreationWithoutOwningGroupIdentity() {
+            final ComponentNode creator = mock(ComponentNode.class);
+            final FlowManager flowManager = mock(FlowManager.class);
+            final StandardControllerServiceFactory factory = new 
StandardControllerServiceFactory(
+                    mock(ExtensionManager.class), flowManager, 
mock(ControllerServiceProvider.class), creator);
+            final ControllerServiceNode service = 
mock(ControllerServiceNode.class);
+            final ControllerServiceCreationDetails details = new 
ControllerServiceCreationDetails("service-id", IMPL_CLASS,
+                    createCoordinate(LONE_BUNDLE, VERSION_2), Map.of(), 
ControllerServiceCreationDetails.CreationState.SERVICE_TO_BE_CREATED);
+            when(flowManager.createControllerService(eq(IMPL_CLASS), 
anyString(), any(), any(), eq(true), eq(true), eq(null))).thenReturn(service);
+            when(creator.getParentProcessGroup()).thenReturn(Optional.empty());
+
+            factory.create(details);
+
+            verify(flowManager).addRootControllerService(service);
+            verify(service, never()).setVersionedComponentId(any());
+
+            final ProcessGroup group = mock(ProcessGroup.class);
+            
when(creator.getParentProcessGroup()).thenReturn(Optional.of(group));
+
+            factory.create(details);
+
+            verify(group).addControllerService(service);
+            verify(service, never()).setVersionedComponentId(any());
+        }
+
+        private String createServiceAndGetVersionedId(final String 
localGroupId, final String owningGroupIdentity, final String type, final 
Map<String, String> properties) {
+            final ControllerServiceNode service = createService(localGroupId, 
owningGroupIdentity, type, properties, Set.of());
+            final ArgumentCaptor<String> versionedId = 
ArgumentCaptor.forClass(String.class);
+            verify(service).setVersionedComponentId(versionedId.capture());
+            return versionedId.getValue();
+        }
+
+        private ControllerServiceNode createService(final String localGroupId, 
final String owningGroupIdentity, final String type,
+                                                   final Map<String, String> 
properties, final Set<ControllerServiceNode> existingServices) {
+            final ComponentNode component = mock(ComponentNode.class);
+            final ProcessGroup group = mock(ProcessGroup.class);
+            
when(component.getParentProcessGroup()).thenReturn(Optional.of(group));
+            
when(component.getProcessGroupIdentifier()).thenReturn(localGroupId);
+            final FlowManager manager = mock(FlowManager.class);
+            final ControllerServiceNode service = 
mock(ControllerServiceNode.class);
+            
when(service.getPropertyDescriptor(anyString())).thenAnswer(invocation -> {
+                final String name = invocation.getArgument(0);
+                return new 
PropertyDescriptor.Builder().name(name).sensitive(PASSWORD_PROPERTY.equals(name)).build();
+            });
+            final Set<ControllerServiceNode> groupServices = new 
HashSet<>(existingServices);
+            groupServices.add(service);
+            when(group.getControllerServices(false)).thenReturn(groupServices);
+            when(manager.createControllerService(eq(type), anyString(), any(), 
any(), eq(true), eq(true), eq(null))).thenReturn(service);
+
+            final StandardControllerServiceFactory serviceFactory = new 
StandardControllerServiceFactory(
+                    mock(ExtensionManager.class), manager, 
mock(ControllerServiceProvider.class), component, owningGroupIdentity);
+            final String instanceId = serviceFactory.determineServiceId(type, 
properties);
+            final ControllerServiceCreationDetails details = new 
ControllerServiceCreationDetails(instanceId, type, 
createCoordinate(LONE_BUNDLE, VERSION_2), properties,
+                    
ControllerServiceCreationDetails.CreationState.SERVICE_TO_BE_CREATED);
+            serviceFactory.create(details);
+
+            verify(service).setProperties(properties);
+            return service;
+        }
+    }

Review Comment:
   [Claude Opus 5.5]
   
   Once the factory only records the services it creates, this nested class can 
be removed. The recording is simple wiring, and the synchronizer tests cover 
it. That also removes two assertions about behavior this PR doesn't change: the 
instance ID assertion on line 200, and the controller-level service path in 
`testCreationWithoutOwningGroupIdentity`.
   
   For what it's worth, the sensitive-value test passed only because the mock 
service on line 277 already recognizes `Password` under the exact name passed 
in. The real services only learn the current name in their own migration. The 
synchronizer test should use an old name (see my comment there).



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to