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 c9ec26629e3 NIFI-15930 Inherited Controller Services before Parameter 
Providers for Secrets (#11252)
c9ec26629e3 is described below

commit c9ec26629e34dd1da5fe539af285ddf8a4b6f089
Author: Bob Paulin <[email protected]>
AuthorDate: Thu May 14 11:41:09 2026 -0500

    NIFI-15930 Inherited Controller Services before Parameter Providers for 
Secrets (#11252)
    
    - Moved inherit Controller Services before inherit Parameter Providers
    - Enable Working Context to apply any changes in Connector Property Values 
and Resolved values to the Parameter Context of the Working Flow Context
    - Warn when a parameter provider is not found
    
    Signed-off-by: David Handermann <[email protected]>
---
 .../secrets/ParameterProviderSecretsManager.java   |  88 +++++++--
 .../TestParameterProviderSecretsManager.java       |  71 +++++++
 .../nifi/components/connector/ConnectorNode.java   |  21 ++
 .../connector/StandardConnectorNode.java           |  42 ++--
 .../connector/StandardConnectorRepository.java     |  29 ++-
 .../serialization/VersionedFlowSynchronizer.java   |  11 +-
 .../connector/TestStandardConnectorNode.java       |  62 +++++-
 .../connector/TestStandardConnectorRepository.java | 219 ++++++++++++++++++++-
 8 files changed, 481 insertions(+), 62 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/ParameterProviderSecretsManager.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/ParameterProviderSecretsManager.java
index c9cc7bef209..37d7d5c5755 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/ParameterProviderSecretsManager.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/components/connector/secrets/ParameterProviderSecretsManager.java
@@ -50,6 +50,14 @@ public class ParameterProviderSecretsManager implements 
SecretsManager {
     private Duration cacheDuration;
     private final Map<String, CachedSecret> secretCache = new 
ConcurrentHashMap<>();
 
+    // Per-ParameterProvider-id deduplication for the WARN log emitted 
whenever a SecretReference
+    // resolution is skipped because its backing provider is not VALID. An 
entry is present for any
+    // provider id we have already surfaced via WARN; the value is the status 
that was warned about
+    // so the same WARN is not repeated until the status either changes or the 
provider returns to
+    // VALID. Entries for providers that no longer exist in the flow are 
pruned at the top of
+    // getSecretProviders() so the map stays bounded across many flow 
synchronizations.
+    private final Map<String, ValidationStatus> lastWarnedStatus = new 
ConcurrentHashMap<>();
+
     private record CachedSecret(Secret secret, long timestampNanos) {
     }
 
@@ -79,17 +87,41 @@ public class ParameterProviderSecretsManager implements 
SecretsManager {
 
     @Override
     public Set<SecretProvider> getSecretProviders() {
+        final Set<ParameterProviderNode> parameterProviderNodes = 
flowManager.getAllParameterProviders();
+
+        // Drop dedup entries for any Parameter Provider that no longer exists 
in the flow so the map
+        // does not grow unbounded when providers are created INVALID and 
deleted before going VALID.
+        final Set<String> currentProviderIds = new HashSet<>();
+        for (final ParameterProviderNode parameterProviderNode : 
parameterProviderNodes) {
+            final String providerId = parameterProviderNode.getIdentifier();
+            if (providerId != null) {
+                currentProviderIds.add(providerId);
+            }
+        }
+        lastWarnedStatus.keySet().retainAll(currentProviderIds);
+
         final Set<SecretProvider> providers = new HashSet<>();
-        for (final ParameterProviderNode parameterProviderNode : 
flowManager.getAllParameterProviders()) {
+        for (final ParameterProviderNode parameterProviderNode : 
parameterProviderNodes) {
             ValidationStatus validationStatus = 
parameterProviderNode.getValidationStatus();
             if (validationStatus != ValidationStatus.VALID) {
                 validationStatus = parameterProviderNode.performValidation();
             }
             if (validationStatus != ValidationStatus.VALID) {
-                logger.debug("Will not use Parameter Provider {} as a Secret 
Provider because it is not valid", parameterProviderNode.getName());
+                logSkippedInvalidProvider(parameterProviderNode, 
validationStatus);
                 continue;
             }
 
+            // Clear the WARN-dedup state for this provider and surface a 
paired INFO so each
+            // earlier WARN has an explicit recovery line operators can 
correlate against.
+            final String providerId = parameterProviderNode.getIdentifier();
+            if (providerId != null) {
+                final ValidationStatus priorWarnedStatus = 
lastWarnedStatus.remove(providerId);
+                if (priorWarnedStatus != null) {
+                    logger.info("Parameter Provider [{}] (id={}) returned to 
VALID after being logged as {};"
+                                    + " SecretReferences backed by this 
provider will resolve again",
+                            parameterProviderNode.getName(), providerId, 
priorWarnedStatus);
+                }
+            }
             providers.add(new 
ParameterProviderSecretProvider(parameterProviderNode));
         }
 
@@ -143,26 +175,23 @@ public class ParameterProviderSecretsManager implements 
SecretsManager {
     private Map<SecretReference, Secret> fetchSecretsWithoutCache(final 
Set<SecretReference> secretReferences) {
         final Set<SecretProvider> providers = getSecretProviders();
 
-        // Partition secret references by Provider
+        // Partition secret references by Provider. References whose provider 
is non-VALID or absent
+        // are recorded with a null Secret so that callers receive an explicit 
entry for every input.
         final Map<SecretProvider, Set<SecretReference>> referencesByProvider = 
new HashMap<>();
+        final Map<SecretReference, Secret> secrets = new HashMap<>();
         for (final SecretReference secretReference : secretReferences) {
             final SecretProvider provider = findProvider(secretReference, 
providers);
-            referencesByProvider.computeIfAbsent(provider, k -> new 
HashSet<>()).add(secretReference);
+            if (provider == null) {
+                secrets.put(secretReference, null);
+            } else {
+                referencesByProvider.computeIfAbsent(provider, k -> new 
HashSet<>()).add(secretReference);
+            }
         }
 
-        final Map<SecretReference, Secret> secrets = new HashMap<>();
         for (final Map.Entry<SecretProvider, Set<SecretReference>> entry : 
referencesByProvider.entrySet()) {
             final SecretProvider provider = entry.getKey();
             final Set<SecretReference> references = entry.getValue();
 
-            // If no provider found, be sure to map to a null Secret rather 
than skipping
-            if (provider == null) {
-                for (final SecretReference secretReference : references) {
-                    secrets.put(secretReference, null);
-                }
-                continue;
-            }
-
             final List<String> secretNames = references.stream()
                 .map(SecretReference::getFullyQualifiedName)
                 .filter(Objects::nonNull)
@@ -192,6 +221,7 @@ public class ParameterProviderSecretsManager implements 
SecretsManager {
 
         // Partition references into cache hits vs. misses that need fetching
         final Map<SecretProvider, Set<SecretReference>> uncachedByProvider = 
new HashMap<>();
+
         for (final SecretReference secretReference : secretReferences) {
             final String fqn = secretReference.getFullyQualifiedName();
 
@@ -205,7 +235,11 @@ public class ParameterProviderSecretsManager implements 
SecretsManager {
             }
 
             final SecretProvider provider = findProvider(secretReference, 
providers);
-            uncachedByProvider.computeIfAbsent(provider, k -> new 
HashSet<>()).add(secretReference);
+            if (provider == null) {
+                results.put(secretReference, null);
+            } else {
+                uncachedByProvider.computeIfAbsent(provider, k -> new 
HashSet<>()).add(secretReference);
+            }
         }
 
         // Batch fetch uncached secrets grouped by provider
@@ -213,13 +247,6 @@ public class ParameterProviderSecretsManager implements 
SecretsManager {
             final SecretProvider provider = entry.getKey();
             final Set<SecretReference> references = entry.getValue();
 
-            if (provider == null) {
-                for (final SecretReference secretReference : references) {
-                    results.put(secretReference, null);
-                }
-                continue;
-            }
-
             final List<String> secretNames = references.stream()
                 .map(SecretReference::getFullyQualifiedName)
                 .filter(Objects::nonNull)
@@ -266,6 +293,25 @@ public class ParameterProviderSecretsManager implements 
SecretsManager {
         }
     }
 
+    private void logSkippedInvalidProvider(final ParameterProviderNode 
parameterProviderNode, final ValidationStatus status) {
+        final String providerId = parameterProviderNode.getIdentifier();
+        if (providerId == null) {
+            return;
+        }
+
+        // Treat a null status (e.g., a mock or transient lookup failure) as 
INVALID for deduplication
+        // purposes so the tracked status is always well-defined.
+        final ValidationStatus effectiveStatus = status == null ? 
ValidationStatus.INVALID : status;
+        final ValidationStatus priorStatus = lastWarnedStatus.put(providerId, 
effectiveStatus);
+        if (priorStatus == effectiveStatus) {
+            return;
+        }
+
+        logger.warn("Skipping Parameter Provider [{}] (id={}) as a Secret 
Provider because its current validation status is {}; "
+                        + "SecretReferences backed by this provider will 
resolve to null until it returns to VALID",
+                parameterProviderNode.getName(), providerId, effectiveStatus);
+    }
+
     private SecretProvider findProvider(final SecretReference secretReference, 
final Set<SecretProvider> providers) {
         // Search first by Provider ID, if it's provided.
         final String providerId = secretReference.getProviderId();
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/components/connector/secrets/TestParameterProviderSecretsManager.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/components/connector/secrets/TestParameterProviderSecretsManager.java
index 6d5545c9fca..7ba784df077 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/components/connector/secrets/TestParameterProviderSecretsManager.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/components/connector/secrets/TestParameterProviderSecretsManager.java
@@ -575,5 +575,76 @@ public class TestParameterProviderSecretsManager {
         manager.initialize(initContext);
         return manager;
     }
+
+    @Test
+    public void testGetSecretsReturnsNullValueWhenProviderIsInvalid() {
+        final FlowManager flowManager = mock(FlowManager.class);
+        final ParameterProviderNode invalidProvider = 
createMockedParameterProviderNode(PROVIDER_1_ID, PROVIDER_1_NAME, GROUP_1_NAME,
+            ValidationStatus.INVALID, createParameter(SECRET_1_NAME, 
SECRET_1_DESCRIPTION, SECRET_1_VALUE));
+        // performValidation should also return INVALID so the provider is 
consistently filtered out.
+        
when(invalidProvider.performValidation()).thenReturn(ValidationStatus.INVALID);
+        
when(flowManager.getAllParameterProviders()).thenReturn(Set.of(invalidProvider));
+
+        final ParameterProviderSecretsManager manager = new 
ParameterProviderSecretsManager();
+        manager.initialize(new 
StandardSecretsManagerInitializationContext(flowManager));
+
+        final SecretReference reference = createSecretReference(PROVIDER_1_ID, 
PROVIDER_1_NAME, SECRET_1_NAME);
+
+        final Map<SecretReference, Secret> results = 
manager.getSecrets(Set.of(reference));
+
+        assertEquals(1, results.size());
+        assertTrue(results.containsKey(reference));
+        assertNull(results.get(reference));
+    }
+
+    @Test
+    public void 
testGetSecretsResolvesReferenceOnceProviderTransitionsToValid() {
+        final FlowManager flowManager = mock(FlowManager.class);
+        final ParameterProviderNode flippingProvider = 
createMockedParameterProviderNode(PROVIDER_1_ID, PROVIDER_1_NAME, GROUP_1_NAME,
+            ValidationStatus.INVALID, createParameter(SECRET_1_NAME, 
SECRET_1_DESCRIPTION, SECRET_1_VALUE));
+        
when(flippingProvider.performValidation()).thenReturn(ValidationStatus.INVALID);
+        
when(flowManager.getAllParameterProviders()).thenReturn(Set.of(flippingProvider));
+
+        final ParameterProviderSecretsManager manager = new 
ParameterProviderSecretsManager();
+        manager.initialize(new 
StandardSecretsManagerInitializationContext(flowManager,
+            Map.of(NiFiProperties.SECRETS_MANAGER_CACHE_DURATION, 
DEFAULT_CACHE_DURATION)));
+
+        final SecretReference reference = createSecretReference(PROVIDER_1_ID, 
PROVIDER_1_NAME, SECRET_1_NAME);
+
+        // While the provider is INVALID the secret is unresolvable.
+        assertNull(manager.getSecrets(Set.of(reference)).get(reference));
+
+        // Once the provider transitions to VALID a follow-up call resolves 
the value. The cache only
+        // stores non-null secrets, so the prior unresolved attempt does not 
block this re-attempt.
+        
when(flippingProvider.getValidationStatus()).thenReturn(ValidationStatus.VALID);
+        
when(flippingProvider.performValidation()).thenReturn(ValidationStatus.VALID);
+
+        final Secret resolved = 
manager.getSecrets(Set.of(reference)).get(reference);
+        assertNotNull(resolved);
+        assertEquals(SECRET_1_VALUE, resolved.getValue());
+    }
+
+    @Test
+    public void 
testGetSecretProvidersFiltersConsistentlyAcrossValidationStatusTransitions() {
+        final FlowManager flowManager = mock(FlowManager.class);
+        final ParameterProviderNode provider = 
createMockedParameterProviderNode(PROVIDER_1_ID, PROVIDER_1_NAME, GROUP_1_NAME,
+            ValidationStatus.INVALID, createParameter(SECRET_1_NAME, 
SECRET_1_DESCRIPTION, SECRET_1_VALUE));
+        
when(provider.performValidation()).thenReturn(ValidationStatus.INVALID);
+        
when(flowManager.getAllParameterProviders()).thenReturn(Set.of(provider));
+
+        final ParameterProviderSecretsManager manager = new 
ParameterProviderSecretsManager();
+        manager.initialize(new 
StandardSecretsManagerInitializationContext(flowManager));
+
+        assertTrue(manager.getSecretProviders().isEmpty());
+        assertTrue(manager.getSecretProviders().isEmpty());
+
+        
when(provider.getValidationStatus()).thenReturn(ValidationStatus.VALID);
+        when(provider.performValidation()).thenReturn(ValidationStatus.VALID);
+        assertEquals(1, manager.getSecretProviders().size());
+
+        
when(provider.getValidationStatus()).thenReturn(ValidationStatus.INVALID);
+        
when(provider.performValidation()).thenReturn(ValidationStatus.INVALID);
+        assertTrue(manager.getSecretProviders().isEmpty());
+    }
 }
 
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorNode.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorNode.java
index bec52c28754..08819d38857 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorNode.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorNode.java
@@ -242,6 +242,27 @@ public interface ConnectorNode extends 
ComponentAuthorizable, VersionedComponent
      */
     void setConfiguration(String configurationStepName, StepConfiguration 
configuration) throws FlowUpdateException;
 
+    /**
+     * Replaces the configuration of the named step on the working flow 
context with the given configuration.
+     * Unlike {@link #setConfiguration(String, StepConfiguration)}, which 
merges the incoming properties with
+     * any existing properties for the step, this method treats the supplied 
configuration as the authoritative
+     * full state for the step: any property not present in {@code 
configuration} is removed from the step.
+     *
+     * <p>If applying the configuration changes either the raw or the resolved 
property values, the Connector
+     * is notified via {@link Connector#onConfigurationStepConfigured} so the 
embedded flow and Parameter
+     * Context can be brought up to date. If nothing changed, no notification 
is performed.</p>
+     *
+     * <p>Intended for use by the framework when reconciling the working flow 
context against an external
+     * {@link ConnectorConfigurationProvider}, whose view is treated as 
authoritative. This method should
+     * only be invoked via the ConnectorRepository.</p>
+     *
+     * @param configurationStepName the name of the configuration step being 
replaced
+     *                              (must match one of the names returned by 
{@link Connector#getConfigurationSteps()})
+     * @param configuration the full configuration for the given configuration 
step
+     * @throws FlowUpdateException if unable to apply the configuration changes
+     */
+    void replaceWorkingConfiguration(String configurationStepName, 
StepConfiguration configuration) throws FlowUpdateException;
+
     void transitionStateForUpdating();
 
     void prepareForUpdate() throws FlowUpdateException;
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java
index a647af3b683..b9659ebb486 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java
@@ -333,18 +333,30 @@ public class StandardConnectorNode implements 
ConnectorNode {
     }
 
     private void setConfiguration(final String stepName, final 
StepConfiguration configuration, final boolean 
forceOnConfigurationStepConfigured) throws FlowUpdateException {
-        // Update properties and check if the configuration changed.
         final ConfigurationUpdateResult updateResult = 
workingFlowContext.getConfigurationContext().setProperties(stepName, 
configuration);
         if (updateResult == ConfigurationUpdateResult.NO_CHANGES && 
!forceOnConfigurationStepConfigured) {
             return;
         }
+        notifyStepConfigured(stepName);
+    }
 
-        // If there were changes, trigger Processor to be notified of the 
change.
+    @Override
+    public void replaceWorkingConfiguration(final String stepName, final 
StepConfiguration configuration) throws FlowUpdateException {
+        // The configuration provider's view is authoritative: any property 
absent from the provided
+        // configuration is removed from the step.
+        final ConfigurationUpdateResult updateResult = 
workingFlowContext.getConfigurationContext().replaceProperties(stepName, 
configuration);
+        if (updateResult == ConfigurationUpdateResult.NO_CHANGES) {
+            return;
+        }
+        notifyStepConfigured(stepName);
+    }
+
+    private void notifyStepConfigured(final String stepName) throws 
FlowUpdateException {
         final Connector connector = connectorDetails.getConnector();
         try (final NarCloseable ignored = 
NarCloseable.withComponentNarLoader(extensionManager, connector.getClass(), 
getIdentifier())) {
             logger.debug("Notifying {} of configuration change for 
configuration step {}", this, stepName);
             connector.onConfigurationStepConfigured(stepName, 
workingFlowContext);
-            logger.debug("Successfully set configuration for step {} on {}", 
stepName, this);
+            logger.debug("Successfully notified {} of configuration change for 
step {}", this, stepName);
         } catch (final FlowUpdateException e) {
             throw e;
         } catch (final Exception e) {
@@ -861,34 +873,26 @@ public class StandardConnectorNode implements 
ConnectorNode {
         workingFlowContext = 
flowContextFactory.createWorkingFlowContext(identifier,
             connectorDetails.getComponentLog(), 
activeFlowContext.getConfigurationContext(), activeFlowContext.getBundle());
 
-        getComponentLog().info("Working Flow Context has been recreated");
+        getComponentLog().info("Working Flow Context has been set");
 
-        synchronizeWorkingFlowParameters();
-    }
-
-    /**
-     * Re-triggers {@link Connector#onConfigurationStepConfigured} for every 
configured step in
-     * the working flow context. This ensures that flow parameters derived 
from the configuration
-     * (e.g., resolved asset paths) are fresh, even when the working context 
was just recreated
-     * from the active flow whose parameter values may be stale.
-     *
-     * <p>Skipped when {@code initializationContext} is {@code null} because 
the connector has
-     * not yet been initialized and there is no flow to update.</p>
-     */
-    private void synchronizeWorkingFlowParameters() {
+        // Re-fire onConfigurationStepConfigured for every step so flow 
parameters derived from the
+        // configuration (e.g., resolved asset paths, secret values) are 
refreshed against the new
+        // working context. Step failures are logged so the remaining steps 
can still be refreshed.
+        // Skipped before the connector has been initialized because there is 
no flow to update yet.
         if (initializationContext == null) {
             return;
         }
-
         final ConnectorConfiguration config = 
workingFlowContext.getConfigurationContext().toConnectorConfiguration();
         for (final NamedStepConfiguration stepConfig : 
config.getNamedStepConfigurations()) {
             try {
                 setConfiguration(stepConfig.stepName(), 
stepConfig.configuration(), true);
             } catch (final Exception e) {
-                logger.warn("Failed to synchronize working flow parameters for 
step [{}] of {}",
+                logger.warn("Failed to refresh resolved configuration for step 
[{}] of {}",
                     stepConfig.stepName(), this, e);
             }
         }
+
+        getComponentLog().info("Working Flow Context configuration has been 
refreshed");
     }
 
     @Override
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
index 3e2d8ede2ea..bdb527fecc8 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
@@ -813,15 +813,26 @@ public class StandardConnectorRepository implements 
ConnectorRepository {
         }
 
         final List<VersionedConfigurationStep> workingFlowConfiguration = 
config.getWorkingFlowConfiguration();
-        if (workingFlowConfiguration != null) {
-            // Enrich provider-sourced SECRET_REFERENCE values with providerId 
before they are
-            // converted into the in-memory ConnectorValueReference graph.
-            resolveSecretReferencesFromProvider(workingFlowConfiguration);
-
-            final MutableConnectorConfigurationContext workingConfigContext = 
connector.getWorkingFlowContext().getConfigurationContext();
-            for (final VersionedConfigurationStep step : 
workingFlowConfiguration) {
-                final StepConfiguration stepConfiguration = 
toStepConfiguration(step);
-                workingConfigContext.replaceProperties(step.getName(), 
stepConfiguration);
+
+        if (workingFlowConfiguration == null) {
+            return;
+        }
+
+        // Enrich provider-sourced SECRET_REFERENCE values with providerId 
before they are
+        // converted into the in-memory ConnectorValueReference graph.
+        resolveSecretReferencesFromProvider(workingFlowConfiguration);
+
+        // Replace each step's working configuration on the connector. Routing 
through the connector
+        // (rather than touching the configuration context directly) ensures 
it is notified via
+        // onConfigurationStepConfigured when raw or resolved property values 
changed, so the embedded
+        // flow's Parameter Context picks up new values (e.g., rotated 
secrets) without an explicit save.
+        for (final VersionedConfigurationStep step : workingFlowConfiguration) 
{
+            final StepConfiguration stepConfiguration = 
toStepConfiguration(step);
+            try {
+                connector.replaceWorkingConfiguration(step.getName(), 
stepConfiguration);
+            } catch (final Exception e) {
+                logger.warn("Failed to replace working configuration for step 
[{}] on {} during sync from provider; continuing with remaining steps",
+                        step.getName(), connector, e);
             }
         }
     }
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 52fc732be7a..ba409ee7d44 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
@@ -417,15 +417,12 @@ public class VersionedFlowSynchronizer implements 
FlowSynchronizer {
                 
versionedExternalFlow.setParameterContexts(versionedParameterContextMap);
                 
versionedExternalFlow.setFlowContents(versionedFlow.getRootGroup());
 
-                // Inherit Parameter Providers and Connectors first. Because 
Connectors are a bit different, in that updates could result in Exceptions 
being thrown,
-                // due to the fact that they manipulate the flow, and changes 
can be aborted, we handle them first. This way, if there's any Exception,
-                // we can fail before updating parts of the flow that are not 
managed by Connectors.
-                // Because Connectors may depend on Parameter Providers, we 
need to ensure that we inherit Parameter Providers first.
+                // Inherit root-level Controller Services first so that 
Parameter Providers backed by
+                // them are VALID when Connectors begin resolving 
SecretReferences and so that any other
+                // controller-level component that references a root CS sees 
it in its target state.
+                inheritControllerServices(controller, versionedFlow, 
affectedComponentSet);
                 inheritParameterProviders(controller, versionedFlow, 
affectedComponentSet);
                 inheritConnectors(controller, versionedFlow);
-
-                // Inherit controller-level components.
-                inheritControllerServices(controller, versionedFlow, 
affectedComponentSet);
                 inheritParameterContexts(controller, versionedFlow);
                 inheritReportingTasks(controller, versionedFlow, 
affectedComponentSet);
                 inheritFlowAnalysisRules(controller, versionedFlow, 
affectedComponentSet);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java
index 365c19dbff8..00e136a6635 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java
@@ -491,7 +491,67 @@ public class TestStandardConnectorNode {
     }
 
     @Test
-    public void testSynchronizeWorkingFlowParametersContinuesOnStepFailure() 
throws FlowUpdateException {
+    public void 
testReplaceWorkingConfigurationReplacesPropertiesAndFiresWhenChanged() throws 
FlowUpdateException {
+        final TrackingConnector trackingConnector = new TrackingConnector();
+        final StandardConnectorNode connectorNode = 
createConnectorNode(trackingConnector);
+
+        connectorNode.transitionStateForUpdating();
+        connectorNode.prepareForUpdate();
+        connectorNode.setConfiguration("step1", 
createStepConfiguration(Map.of("propA", "valueA", "propB", "valueB")));
+        connectorNode.applyUpdate();
+
+        trackingConnector.reset();
+
+        connectorNode.replaceWorkingConfiguration("step1", 
createStepConfiguration(Map.of("propA", "newA")));
+
+        
assertTrue(trackingConnector.wasOnPropertyGroupConfiguredCalled("step1"));
+        final ConnectorConfiguration workingConfig = 
connectorNode.getWorkingFlowContext().getConfigurationContext().toConnectorConfiguration();
+        final NamedStepConfiguration namedStep = 
workingConfig.getNamedStepConfigurations().iterator().next();
+        assertEquals("step1", namedStep.stepName());
+        assertEquals(Map.of("propA", new StringLiteralValue("newA")), 
namedStep.configuration().getPropertyValues());
+    }
+
+    @Test
+    public void testReplaceWorkingConfigurationDoesNotFireWhenUnchanged() 
throws FlowUpdateException {
+        final TrackingConnector trackingConnector = new TrackingConnector();
+        final StandardConnectorNode connectorNode = 
createConnectorNode(trackingConnector);
+
+        connectorNode.transitionStateForUpdating();
+        connectorNode.prepareForUpdate();
+        connectorNode.setConfiguration("step1", 
createStepConfiguration(Map.of("propA", "valueA")));
+        connectorNode.applyUpdate();
+
+        trackingConnector.reset();
+
+        connectorNode.replaceWorkingConfiguration("step1", 
createStepConfiguration(Map.of("propA", "valueA")));
+
+        
assertFalse(trackingConnector.wasOnPropertyGroupConfiguredCalled("step1"));
+    }
+
+    @Test
+    public void 
testDiscardWorkingConfigurationFiresOnConfiguredForEveryWorkingStep() throws 
FlowUpdateException {
+        final TrackingConnector trackingConnector = new TrackingConnector();
+        final StandardConnectorNode connectorNode = 
createConnectorNode(trackingConnector);
+
+        connectorNode.transitionStateForUpdating();
+        connectorNode.prepareForUpdate();
+        connectorNode.setConfiguration("step1", 
createStepConfiguration(Map.of("propA", "valueA")));
+        connectorNode.setConfiguration("step2", 
createStepConfiguration(Map.of("propB", "valueB")));
+        connectorNode.applyUpdate();
+
+        trackingConnector.reset();
+
+        // Recreating the working flow context from the active flow must fire 
onConfigurationStepConfigured
+        // for every working configuration step so that flow parameters 
derived from the configuration
+        // (resolved asset paths, secrets, etc.) are refreshed.
+        connectorNode.discardWorkingConfiguration();
+
+        
assertTrue(trackingConnector.wasOnPropertyGroupConfiguredCalled("step1"));
+        
assertTrue(trackingConnector.wasOnPropertyGroupConfiguredCalled("step2"));
+    }
+
+    @Test
+    public void testDiscardWorkingConfigurationContinuesOnStepFailure() throws 
FlowUpdateException {
         final FailingStepConnector failingStepConnector = new 
FailingStepConnector("failingStep");
         final StandardConnectorNode connectorNode = 
createConnectorNode(failingStepConnector);
 
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
index 8560cb6f8bc..cba4b12f574 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
@@ -19,14 +19,24 @@ package org.apache.nifi.components.connector;
 
 import org.apache.nifi.asset.Asset;
 import org.apache.nifi.asset.AssetManager;
+import org.apache.nifi.bundle.BundleCoordinate;
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.connector.components.FlowContext;
+import org.apache.nifi.components.connector.components.FlowContextType;
+import org.apache.nifi.components.connector.secrets.SecretsManager;
 import org.apache.nifi.controller.ParameterProviderNode;
 import org.apache.nifi.controller.flow.FlowManager;
+import org.apache.nifi.controller.queue.QueueSize;
 import org.apache.nifi.flow.Bundle;
 import org.apache.nifi.flow.ScheduledState;
 import org.apache.nifi.flow.VersionedConfigurationStep;
 import org.apache.nifi.flow.VersionedConnector;
 import org.apache.nifi.flow.VersionedConnectorValueReference;
+import org.apache.nifi.flow.VersionedExternalFlow;
+import org.apache.nifi.groups.ProcessGroup;
+import org.apache.nifi.logging.ComponentLog;
 import org.apache.nifi.nar.ExtensionManager;
+import org.apache.nifi.util.MockComponentLog;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 
@@ -35,7 +45,9 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.time.Duration;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -43,6 +55,7 @@ import java.util.Set;
 import java.util.concurrent.CompletableFuture;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNotSame;
 import static org.junit.jupiter.api.Assertions.assertNull;
@@ -50,6 +63,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anySet;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.doThrow;
@@ -219,7 +233,7 @@ public class TestStandardConnectorRepository {
     }
 
     @Test
-    public void testGetConnectorWithProviderOverridesWorkingConfig() {
+    public void testGetConnectorWithProviderOverridesWorkingConfig() throws 
FlowUpdateException {
         final ConnectorConfigurationProvider provider = 
mock(ConnectorConfigurationProvider.class);
         final StandardConnectorRepository repository = 
createRepositoryWithProvider(provider);
 
@@ -237,7 +251,7 @@ public class TestStandardConnectorRepository {
 
         assertNotNull(result);
         verify(connector).setName("External Name");
-        verify(workingConfigContext).replaceProperties(eq("step1"), 
any(StepConfiguration.class));
+        verify(connector).replaceWorkingConfiguration(eq("step1"), 
any(StepConfiguration.class));
     }
 
     @Test
@@ -689,7 +703,7 @@ public class TestStandardConnectorRepository {
     }
 
     @Test
-    public void testSyncFromProviderAppliesNifiUuidsDirectly() {
+    public void testSyncFromProviderAppliesNifiUuidsDirectly() throws 
FlowUpdateException {
         final ConnectorConfigurationProvider provider = 
mock(ConnectorConfigurationProvider.class);
         final StandardConnectorRepository repository = 
createRepositoryWithProvider(provider);
 
@@ -710,8 +724,85 @@ public class TestStandardConnectorRepository {
 
         repository.getConnector("connector-1", 
ConnectorSyncMode.SYNC_WITH_PROVIDER);
 
-        // Working config is updated with NiFi UUIDs as-is -- no translation 
in the repository
-        verify(workingConfigContext).replaceProperties(eq("step1"), 
any(StepConfiguration.class));
+        // Working config is replaced with NiFi UUIDs as-is -- no translation 
in the repository
+        verify(connector).replaceWorkingConfiguration(eq("step1"), 
any(StepConfiguration.class));
+    }
+
+    @Test
+    public void 
testGetConnectorTriggersOnConfigurationStepConfiguredWhenValuesChange() throws 
FlowUpdateException {
+        final ConnectorConfigurationProvider provider = 
mock(ConnectorConfigurationProvider.class);
+        final StandardConnectorRepository repository = 
createRepositoryWithProvider(provider);
+
+        final TrackingConnector trackingConnector = new TrackingConnector();
+        final StandardConnectorNode connectorNode = 
createRealConnectorNode("connector-1", trackingConnector);
+        seedWorkingConfiguration(connectorNode, "step1", Map.of("prop1", 
"initial-value"));
+        repository.restoreConnector(connectorNode);
+
+        trackingConnector.reset();
+
+        final VersionedConfigurationStep externalStep = 
createVersionedStep("step1",
+                Map.of("prop1", createStringLiteralRef("updated-value")));
+        final ConnectorWorkingConfiguration externalConfig = new 
ConnectorWorkingConfiguration();
+        externalConfig.setName("connector-1");
+        externalConfig.setWorkingFlowConfiguration(List.of(externalStep));
+        
when(provider.load("connector-1")).thenReturn(Optional.of(externalConfig));
+
+        final ConnectorNode result = repository.getConnector("connector-1", 
ConnectorSyncMode.SYNC_WITH_PROVIDER);
+
+        assertNotNull(result);
+        assertTrue(trackingConnector.wasOnStepConfiguredCalled("step1"));
+    }
+
+    @Test
+    public void 
testGetConnectorDoesNotTriggerOnConfigurationStepConfiguredWhenValuesUnchanged()
 throws FlowUpdateException {
+        final ConnectorConfigurationProvider provider = 
mock(ConnectorConfigurationProvider.class);
+        final StandardConnectorRepository repository = 
createRepositoryWithProvider(provider);
+
+        final TrackingConnector trackingConnector = new TrackingConnector();
+        final StandardConnectorNode connectorNode = 
createRealConnectorNode("connector-1", trackingConnector);
+        seedWorkingConfiguration(connectorNode, "step1", Map.of("prop1", 
"same-value"));
+        repository.restoreConnector(connectorNode);
+
+        trackingConnector.reset();
+
+        final VersionedConfigurationStep externalStep = 
createVersionedStep("step1",
+                Map.of("prop1", createStringLiteralRef("same-value")));
+        final ConnectorWorkingConfiguration externalConfig = new 
ConnectorWorkingConfiguration();
+        externalConfig.setName("connector-1");
+        externalConfig.setWorkingFlowConfiguration(List.of(externalStep));
+        
when(provider.load("connector-1")).thenReturn(Optional.of(externalConfig));
+
+        final ConnectorNode result = repository.getConnector("connector-1", 
ConnectorSyncMode.SYNC_WITH_PROVIDER);
+
+        assertNotNull(result);
+        assertFalse(trackingConnector.wasOnStepConfiguredCalled("step1"));
+    }
+
+    @Test
+    public void testGetConnectorContinuesWhenOneStepFailsToReplace() throws 
FlowUpdateException {
+        final ConnectorConfigurationProvider provider = 
mock(ConnectorConfigurationProvider.class);
+        final StandardConnectorRepository repository = 
createRepositoryWithProvider(provider);
+
+        final ConnectorNode connector = mock(ConnectorNode.class);
+        when(connector.getIdentifier()).thenReturn("connector-1");
+        doThrow(new FlowUpdateException("Simulated failure for step1"))
+                .when(connector).replaceWorkingConfiguration(eq("step1"), 
any(StepConfiguration.class));
+        repository.addConnector(connector);
+
+        final VersionedConfigurationStep stepOne = createVersionedStep("step1",
+                Map.of("prop1", createStringLiteralRef("value1")));
+        final VersionedConfigurationStep stepTwo = createVersionedStep("step2",
+                Map.of("prop2", createStringLiteralRef("value2")));
+        final ConnectorWorkingConfiguration externalConfig = new 
ConnectorWorkingConfiguration();
+        externalConfig.setName("connector-1");
+        externalConfig.setWorkingFlowConfiguration(List.of(stepOne, stepTwo));
+        
when(provider.load("connector-1")).thenReturn(Optional.of(externalConfig));
+
+        final ConnectorNode result = repository.getConnector("connector-1", 
ConnectorSyncMode.SYNC_WITH_PROVIDER);
+
+        assertNotNull(result);
+        verify(connector).replaceWorkingConfiguration(eq("step1"), 
any(StepConfiguration.class));
+        verify(connector).replaceWorkingConfiguration(eq("step2"), 
any(StepConfiguration.class));
     }
 
     @Test
@@ -1379,6 +1470,80 @@ public class TestStandardConnectorRepository {
         return vc;
     }
 
+    private StandardConnectorNode createRealConnectorNode(final String 
identifier, final Connector connector) throws FlowUpdateException {
+        final ExtensionManager extensionManager = mock(ExtensionManager.class);
+        final AssetManager assetManager = mock(AssetManager.class);
+        final SecretsManager secretsManager = mock(SecretsManager.class);
+        when(secretsManager.getAllSecrets()).thenReturn(List.of());
+        
when(secretsManager.getSecrets(anySet())).thenReturn(Collections.emptyMap());
+
+        final ProcessGroup managedProcessGroup = mock(ProcessGroup.class);
+        
when(managedProcessGroup.purge()).thenReturn(CompletableFuture.completedFuture(null));
+        when(managedProcessGroup.getQueueSize()).thenReturn(new QueueSize(0, 
0L));
+
+        final FlowContextFactory flowContextFactory = new FlowContextFactory() 
{
+            @Override
+            public FrameworkFlowContext createActiveFlowContext(final String 
connectorId, final ComponentLog connectorLogger, final Bundle bundle) {
+                final MutableConnectorConfigurationContext 
activeConfigurationContext =
+                        new 
StandardConnectorConfigurationContext(assetManager, secretsManager);
+                return new StandardFlowContext(managedProcessGroup, 
activeConfigurationContext,
+                        mock(ProcessGroupFacadeFactory.class), 
mock(ParameterContextFacadeFactory.class),
+                        connectorLogger, FlowContextType.ACTIVE, bundle);
+            }
+
+            @Override
+            public FrameworkFlowContext createWorkingFlowContext(final String 
connectorId, final ComponentLog connectorLogger,
+                    final MutableConnectorConfigurationContext 
currentConfiguration, final Bundle bundle) {
+                return new StandardFlowContext(managedProcessGroup, 
currentConfiguration,
+                        mock(ProcessGroupFacadeFactory.class), 
mock(ParameterContextFacadeFactory.class),
+                        connectorLogger, FlowContextType.WORKING, bundle);
+            }
+        };
+
+        final ConnectorStateTransition stateTransition = new 
StandardConnectorStateTransition("TestConnector-" + identifier);
+        final ConnectorValidationTrigger validationTrigger = new 
ConnectorValidationTrigger() {
+            @Override
+            public void triggerAsync(final ConnectorNode node) {
+                node.performValidation();
+            }
+
+            @Override
+            public void trigger(final ConnectorNode node) {
+                node.performValidation();
+            }
+        };
+
+        final ComponentLog componentLog = new 
MockComponentLog("TestConnector", connector);
+        final BundleCoordinate bundleCoordinate = new 
BundleCoordinate("org.apache.nifi", "test-standard-connector-node", "1.0.0");
+        final ConnectorDetails connectorDetails = new 
ConnectorDetails(connector, bundleCoordinate, componentLog);
+
+        final StandardConnectorNode node = new StandardConnectorNode(
+                identifier, mock(FlowManager.class), extensionManager, null, 
connectorDetails,
+                "TestConnector", connector.getClass().getCanonicalName(),
+                new StandardConnectorConfigurationContext(assetManager, 
secretsManager),
+                stateTransition, flowContextFactory, validationTrigger, false);
+
+        final FrameworkConnectorInitializationContext initializationContext = 
mock(FrameworkConnectorInitializationContext.class);
+        
when(initializationContext.getSecretsManager()).thenReturn(secretsManager);
+        when(initializationContext.getAssetManager()).thenReturn(assetManager);
+
+        node.initializeConnector(initializationContext);
+        node.loadInitialFlow();
+        return node;
+    }
+
+    private void seedWorkingConfiguration(final StandardConnectorNode 
connectorNode, final String stepName,
+            final Map<String, String> properties) throws FlowUpdateException {
+        final Map<String, ConnectorValueReference> references = new 
HashMap<>();
+        for (final Map.Entry<String, String> entry : properties.entrySet()) {
+            references.put(entry.getKey(), new 
StringLiteralValue(entry.getValue()));
+        }
+        connectorNode.transitionStateForUpdating();
+        connectorNode.prepareForUpdate();
+        connectorNode.setConfiguration(stepName, new 
StepConfiguration(references));
+        connectorNode.applyUpdate();
+    }
+
     private StandardConnectorRepository createRepositoryWithShortTimeout(final 
ConnectorConfigurationProvider provider) {
         final StandardConnectorRepository repository = new 
StandardConnectorRepository();
         final ConnectorRepositoryInitializationContext initContext = 
mock(ConnectorRepositoryInitializationContext.class);
@@ -1390,4 +1555,48 @@ public class TestStandardConnectorRepository {
         repository.initialize(initContext);
         return repository;
     }
+
+    /**
+     * Test connector that tracks invocations of onStepConfigured so tests can 
assert which
+     * configuration steps were notified of changes.
+     */
+    private static class TrackingConnector extends AbstractConnector {
+        private final Set<String> onStepConfiguredCalls = new HashSet<>();
+
+        @Override
+        public VersionedExternalFlow getInitialFlow() {
+            return null;
+        }
+
+        @Override
+        public void prepareForUpdate(final FlowContext workingContext, final 
FlowContext activeContext) {
+        }
+
+        @Override
+        public List<ConfigurationStep> getConfigurationSteps() {
+            return List.of();
+        }
+
+        @Override
+        public void applyUpdate(final FlowContext workingContext, final 
FlowContext activeContext) {
+        }
+
+        @Override
+        protected void onStepConfigured(final String stepName, final 
FlowContext workingContext) {
+            onStepConfiguredCalls.add(stepName);
+        }
+
+        @Override
+        public List<ConfigVerificationResult> verifyConfigurationStep(final 
String stepName, final Map<String, String> overrides, final FlowContext 
flowContext) {
+            return List.of();
+        }
+
+        public boolean wasOnStepConfiguredCalled(final String stepName) {
+            return onStepConfiguredCalls.contains(stepName);
+        }
+
+        public void reset() {
+            onStepConfiguredCalls.clear();
+        }
+    }
 }

Reply via email to