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

exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 46fc537ec8a NIFI-16071 Enable newly added Controller Services on 
versioned flow upgrade (#11390)
46fc537ec8a is described below

commit 46fc537ec8ad6e5112a05753ffe4110f133e1c90
Author: Pierre Villard <[email protected]>
AuthorDate: Fri Jul 3 03:01:04 2026 +0200

    NIFI-16071 Enable newly added Controller Services on versioned flow upgrade 
(#11390)
    
    Signed-off-by: David Handermann <[email protected]>
---
 .../StandardVersionedComponentSynchronizer.java    | 10 +++
 .../RetainExistingStateComponentScheduler.java     |  8 ++
 .../RetainExistingStateComponentSchedulerTest.java | 24 ++++++
 .../org/apache/nifi/groups/ComponentScheduler.java | 18 ++++
 .../processors/tests/system/CountFlowFiles.java    | 11 ++-
 .../tests/system/registry/RegistryClientIT.java    | 99 ++++++++++++++++++++++
 6 files changed, 169 insertions(+), 1 deletion(-)

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 a43aca79a8b..d8785cd9a2a 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
@@ -778,6 +778,16 @@ public class StandardVersionedComponentSynchronizer 
implements VersionedComponen
                 
context.getComponentScheduler().enableControllerServicesAsync(Collections.singleton(service));
             }
         });
+
+        // Controller Services are always persisted in a versioned flow as 
DISABLED, so newly added services never appear
+        // in the "proposed state is ENABLED" set above. Let the 
ComponentScheduler decide whether to enable them: on a
+        // versioned-flow upgrade of an active Process Group, 
RetainExistingStateComponentScheduler enables them (mirroring
+        // how newly added processors are started), while startup/restore 
schedulers treat this as a no-op so that a service
+        // that was not previously enabled remains disabled.
+        final Set<ControllerServiceNode> addedServices = new 
HashSet<>(servicesAdded.values());
+        addedServices.removeAll(toEnable);
+        addedServices.forEach(ComponentNode::performValidation);
+        
context.getComponentScheduler().enableAddedControllerServicesAsync(addedServices);
     }
 
     private void removeMissingConnections(final ProcessGroup group, final 
VersionedProcessGroup proposed, final Map<String, Connection> 
connectionsByVersionedId) {
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/RetainExistingStateComponentScheduler.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/RetainExistingStateComponentScheduler.java
index 393b9866bdf..ffcb92a84ee 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/RetainExistingStateComponentScheduler.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/RetainExistingStateComponentScheduler.java
@@ -158,6 +158,14 @@ public class RetainExistingStateComponentScheduler 
implements ComponentScheduler
         delegate.enableControllerServicesAsync(toEnable);
     }
 
+    @Override
+    public void enableAddedControllerServicesAsync(final 
Collection<ControllerServiceNode> controllerServices) {
+        // These services are newly added by the flow update (they were not 
present before). Apply the same policy used
+        // for other newly added components: enable them only if the Process 
Group is active. enableControllerServicesAsync
+        // already implements this gate for services with no previous state, 
so delegate to it.
+        enableControllerServicesAsync(controllerServices);
+    }
+
     @Override
     public void disableControllerServicesAsync(final 
Collection<ControllerServiceNode> controllerServices) {
         delegate.disableControllerServicesAsync(controllerServices);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/groups/RetainExistingStateComponentSchedulerTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/groups/RetainExistingStateComponentSchedulerTest.java
index 8f5add62a79..5456e7c84f1 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/groups/RetainExistingStateComponentSchedulerTest.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/groups/RetainExistingStateComponentSchedulerTest.java
@@ -175,6 +175,30 @@ class RetainExistingStateComponentSchedulerTest {
         verify(delegate).enableControllerServicesAsync(Collections.emptySet());
     }
 
+    @Test
+    void testAddedServiceEnabledWhenProcessGroupActive() {
+        final ProcessorNode runningProcessor = 
createMockProcessor("running-proc", ScheduledState.RUNNING);
+        final ProcessGroup group = 
createProcessGroup(Set.of(runningProcessor), Collections.emptySet(), 
Collections.emptySet());
+        final RetainExistingStateComponentScheduler scheduler = new 
RetainExistingStateComponentScheduler(group, delegate);
+        assertTrue(scheduler.isProcessGroupActive());
+
+        // A service newly added by the update, referenced only by an existing 
processor, must be enabled when the group is active.
+        final ControllerServiceNode addedService = 
createMockService("added-svc", ControllerServiceState.DISABLED);
+        scheduler.enableAddedControllerServicesAsync(List.of(addedService));
+        verify(delegate).enableControllerServicesAsync(Set.of(addedService));
+    }
+
+    @Test
+    void testAddedServiceNotEnabledWhenProcessGroupInactive() {
+        final ProcessGroup group = createProcessGroup(Collections.emptySet(), 
Collections.emptySet(), Collections.emptySet());
+        final RetainExistingStateComponentScheduler scheduler = new 
RetainExistingStateComponentScheduler(group, delegate);
+        assertFalse(scheduler.isProcessGroupActive());
+
+        final ControllerServiceNode addedService = 
createMockService("added-svc", ControllerServiceState.DISABLED);
+        scheduler.enableAddedControllerServicesAsync(List.of(addedService));
+        verify(delegate).enableControllerServicesAsync(Collections.emptySet());
+    }
+
     @Test
     void testExistingEnabledServiceReEnabled() {
         final ControllerServiceNode enabledService = 
createMockService("enabled-svc", ControllerServiceState.ENABLED);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/groups/ComponentScheduler.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/groups/ComponentScheduler.java
index 93edfbf390b..f0c374f35bd 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/groups/ComponentScheduler.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/groups/ComponentScheduler.java
@@ -33,6 +33,24 @@ public interface ComponentScheduler {
 
     void enableControllerServicesAsync(Collection<ControllerServiceNode> 
controllerServices);
 
+    /**
+     * Called during a flow synchronization with the Controller Services that 
are newly added by the synchronization
+     * (i.e., that did not exist in the flow before). Because Controller 
Services are always persisted in a versioned
+     * flow as DISABLED, they are never included in the "proposed state is 
ENABLED" set that is handed to
+     * {@link #enableControllerServicesAsync(Collection)}. This method gives a 
scheduler the opportunity to enable
+     * newly added services based on its own policy.
+     *
+     * <p>The default implementation is a no-op: schedulers that restore 
components to a persisted state (startup,
+     * cluster reconnect) enable services through their normal proposed-state 
handling and must not enable a service
+     * that was not previously enabled. Only a scheduler that retains the 
existing runtime state during an update
+     * (a versioned-flow upgrade) overrides this to enable newly added 
services when the process group is active,
+     * mirroring how newly added processors are started.</p>
+     *
+     * @param controllerServices the Controller Services that were newly added 
by the synchronization
+     */
+    default void 
enableAddedControllerServicesAsync(Collection<ControllerServiceNode> 
controllerServices) {
+    }
+
     void disableControllerServicesAsync(Collection<ControllerServiceNode> 
controllerServices);
 
     void startReportingTask(ReportingTaskNode reportingTask);
diff --git 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/CountFlowFiles.java
 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/CountFlowFiles.java
index c067ffb622e..c4756e15525 100644
--- 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/CountFlowFiles.java
+++ 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/CountFlowFiles.java
@@ -41,13 +41,22 @@ public class CountFlowFiles extends AbstractProcessor {
         .identifiesControllerService(CountService.class)
         .build();
 
+    static final PropertyDescriptor SECONDARY_COUNT_SERVICE = new Builder()
+        .name("Secondary Count Service")
+        .displayName("Secondary Count Service")
+        .description("An optional additional Controller Service to reference. 
This mirrors a new optional controller service "
+            + "reference being introduced on an existing processor in a later 
flow version.")
+        .required(false)
+        .identifiesControllerService(CountService.class)
+        .build();
+
     public static final Relationship REL_SUCCESS = new Relationship.Builder()
         .name("success")
         .build();
 
     @Override
     protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
-        return Collections.singletonList(COUNT_SERVICE);
+        return List.of(COUNT_SERVICE, SECONDARY_COUNT_SERVICE);
     }
 
     @Override
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/RegistryClientIT.java
 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/RegistryClientIT.java
index 4903372c5b2..0d37673225b 100644
--- 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/RegistryClientIT.java
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/RegistryClientIT.java
@@ -550,6 +550,105 @@ public class RegistryClientIT extends NiFiSystemIT {
         }
     }
 
+    /**
+     * Tests that when upgrading an active versioned process group, a newly 
added Controller Service that is referenced only
+     * by an EXISTING processor (via a newly added optional property) is 
enabled by the upgrade.
+     *
+     * This is the scenario observed in production with the Openflow 
MySQL/PostgreSQL connectors: version N+1 introduced a
+     * new optional Controller Service plus a new optional property on an 
already-existing processor that references it.
+     * Controller Services are always persisted in a versioned flow as 
DISABLED (see
+     * {@code VersionedComponentStateLookup.ENABLED_OR_DISABLED}), so the 
upgrade itself must enable the newly added service
+     * for the referencing processor to remain valid and running.
+     *
+     * It differs from {@link #testNewComponentsStartedDuringVersionChange()} 
in one key way: there, the new service is
+     * referenced by a brand-new processor that the upgrade starts. Here, NO 
new component references the new service - only
+     * an existing processor does, through a new optional property.
+     *
+     * v1: GenerateFlowFile -> CountFlowFiles (references "Count Service") -> 
TerminateFlowFile, plus a standalone Heartbeat.
+     * v2: Adds a new StandardCountService and sets the existing 
CountFlowFiles processor's optional "Secondary Count Service"
+     *     property to reference it. A standalone Heartbeat GenerateFlowFile 
stays running so the group is active during the
+     *     upgrade.
+     */
+    @Test
+    public void 
testNewServiceReferencedByExistingProcessorEnabledDuringVersionChange() throws 
NiFiClientException, IOException, InterruptedException {
+        final FlowRegistryClientEntity clientEntity = registerClient();
+        final NiFiClientUtil util = getClientUtil();
+
+        final ProcessGroupEntity group = util.createProcessGroup("Parent", 
"root");
+        final ControllerServiceEntity countService = 
util.createControllerService("StandardCountService", group.getId());
+
+        final ProcessorEntity generate = 
util.createProcessor("GenerateFlowFile", group.getId());
+        final ProcessorEntity countProcessor = 
util.createProcessor("CountFlowFiles", group.getId());
+        util.updateProcessorProperties(countProcessor, 
Collections.singletonMap("Count Service", countService.getComponent().getId()));
+
+        final ProcessorEntity terminate = 
util.createProcessor("TerminateFlowFile", group.getId());
+        final ConnectionEntity connectionToTerminate = 
util.createConnection(countProcessor, terminate, "success");
+        util.setFifoPrioritizer(connectionToTerminate);
+        util.createConnection(generate, countProcessor, "success");
+
+        // Standalone "Heartbeat" processor: present and unchanged in both v1 
and v2, stays running during the upgrade so
+        // that the process group is active when the synchronizer inspects it.
+        final ProcessorEntity heartbeat = 
util.createProcessor("GenerateFlowFile", group.getId());
+        util.setAutoTerminatedRelationships(heartbeat, "success");
+
+        // Save as v1
+        final VersionControlInformationEntity vci = 
util.startVersionControl(group, clientEntity, TEST_FLOWS_BUCKET, 
"ExistingProcessorNewService");
+
+        // Build v2: add a new service and reference it from the EXISTING 
processor via a new optional property.
+        // No new component references the new service.
+        final ControllerServiceEntity newCountService = 
util.createControllerService("StandardCountService", group.getId());
+        util.updateProcessorProperties(countProcessor, 
Collections.singletonMap("Secondary Count Service", 
newCountService.getComponent().getId()));
+
+        // Save as v2
+        util.saveFlowVersion(group, clientEntity, vci);
+
+        // Switch back to v1 and start the flow
+        util.changeFlowVersion(group.getId(), "1");
+        util.assertFlowStaleAndUnmodified(group.getId());
+
+        util.enableControllerService(countService);
+        util.waitForValidProcessor(generate.getId());
+        util.startProcessor(generate);
+        util.waitForValidProcessor(countProcessor.getId());
+        util.startProcessor(countProcessor);
+        util.waitForValidProcessor(heartbeat.getId());
+        util.startProcessor(heartbeat);
+
+        // Verify v1 flow works
+        waitForQueueCount(connectionToTerminate.getId(), getNumberOfNodes());
+        final Map<String, String> v1Attributes = 
util.getQueueFlowFile(connectionToTerminate.getId(), 
0).getFlowFile().getAttributes();
+        assertEquals("1", v1Attributes.get("count"));
+
+        // Upgrade to v2 while the flow is running. The Heartbeat processor 
stays running, so the process group is active.
+        util.changeFlowVersion(group.getId(), "2");
+        util.assertFlowUpToDate(group.getId());
+
+        // The newly added service is referenced only by the existing 
CountFlowFiles processor via the new optional property.
+        // Existing component ids are stable across a version change, so the 
newly added service is the one whose id is not
+        // the pre-existing countService.
+        final Set<ControllerServiceEntity> servicesAfterUpgrade = 
getNifiClient().getFlowClient()
+            .getControllerServices(group.getId()).getControllerServices();
+        assertEquals(2, servicesAfterUpgrade.size(), "Expected the 
pre-existing and the newly added Controller Service");
+        final ControllerServiceEntity newServiceAfterUpgrade = 
servicesAfterUpgrade.stream()
+            .filter(service -> !service.getId().equals(countService.getId()))
+            .findFirst()
+            .orElseThrow();
+
+        // Because the group was active during the upgrade, the newly added 
service must be enabled so that the existing
+        // processor that references it stays valid and running. Give 
enablement a chance to complete, then assert
+        // explicitly: waitForControllerServiceState returns without throwing 
on timeout, so an explicit assertion is
+        // needed to surface the failure clearly (rather than only as a 
downstream "processor never started" timeout).
+        util.waitForControllerServicesEnabled(group.getId(), 
newServiceAfterUpgrade.getId());
+        final ControllerServiceEntity newServiceState = 
getNifiClient().getControllerServicesClient()
+            .getControllerService(newServiceAfterUpgrade.getId());
+        assertEquals("ENABLED", newServiceState.getComponent().getState(),
+            "Controller Service '" + newServiceState.getComponent().getName() 
+ "' (" + newServiceState.getId() + "), newly added and "
+                + "referenced only by an existing processor via a new optional 
property, should be ENABLED after an active-group upgrade");
+
+        // The existing processor that references the newly added service must 
be valid and running again.
+        util.waitForRunningProcessor(countProcessor.getId());
+    }
+
     /**
      * Test that a parent Process Group can be committed to a Flow Registry 
even when a child Process Group
      * that is separately under Version Control has local modifications. The 
parent's snapshot only stores

Reply via email to