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

markap14 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 328aa97bc56 NIFI-15671 - Flow import/export with stateful components 
state (#10986)
328aa97bc56 is described below

commit 328aa97bc56867f7f11ff6900fa6572dc782d910
Author: Pierre Villard <[email protected]>
AuthorDate: Mon May 11 21:33:56 2026 +0200

    NIFI-15671 - Flow import/export with stateful components state (#10986)
    
    * NIFI-15671 - Flow import/export with stateful components state
    
    Signed-off-by: Pierre Villard <[email protected]>
---
 .../http/StandardHttpResponseMapper.java           |   2 +
 .../ExportProcessGroupEndpointMerger.java          | 156 +++++++
 .../http/endpoints/ExportedFlowSnapshotEntity.java |  91 ++++
 .../StandardVersionedComponentSynchronizer.java    | 114 +++++
 .../VersionedFlowSynchronizationContext.java       |  37 ++
 .../apache/nifi/groups/StandardProcessGroup.java   |  10 +
 .../flow/mapping/VersionedComponentFlowMapper.java |  61 +++
 .../nifi/groups/StandardProcessGroupTest.java      |   5 +
 .../mapping/TestVersionedComponentFlowMapper.java  | 106 +++++
 .../nifi/controller/ClusterTopologyProvider.java   |  40 ++
 .../registry/flow/mapping/FlowMappingOptions.java  |  64 +++
 .../org/apache/nifi/controller/FlowController.java |  40 +-
 .../nifi/controller/flow/StandardFlowManager.java  |   2 +-
 .../org/apache/nifi/web/NiFiServiceFacade.java     |  12 +
 .../apache/nifi/web/StandardNiFiServiceFacade.java |  87 +++-
 .../apache/nifi/web/api/ProcessGroupResource.java  |  70 ++-
 .../nifi/web/api/TestProcessGroupResource.java     |   2 +-
 .../stateless/engine/StatelessFlowManager.java     |  14 +
 .../tests/system/StatefulCountProcessor.java       |  70 +++
 .../services/org.apache.nifi.processor.Processor   |   1 +
 .../ClusterFlowDefinitionExportImportStateIT.java  | 371 ++++++++++++++++
 .../pg/FlowDefinitionExportImportStateIT.java      | 484 +++++++++++++++++++++
 .../nifi/toolkit/client/ProcessGroupClient.java    |   2 +
 .../client/impl/JerseyProcessGroupClient.java      |   9 +-
 24 files changed, 1827 insertions(+), 23 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/StandardHttpResponseMapper.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/StandardHttpResponseMapper.java
index efa017ae5f7..6c2b43a3239 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/StandardHttpResponseMapper.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/StandardHttpResponseMapper.java
@@ -44,6 +44,7 @@ import 
org.apache.nifi.cluster.coordination.http.endpoints.CountersEndpointMerge
 import 
org.apache.nifi.cluster.coordination.http.endpoints.CurrentUserEndpointMerger;
 import 
org.apache.nifi.cluster.coordination.http.endpoints.DropAllFlowFilesRequestEndpointMerger;
 import 
org.apache.nifi.cluster.coordination.http.endpoints.DropRequestEndpointMerger;
+import 
org.apache.nifi.cluster.coordination.http.endpoints.ExportProcessGroupEndpointMerger;
 import 
org.apache.nifi.cluster.coordination.http.endpoints.FlowAnalysisEndpointMerger;
 import 
org.apache.nifi.cluster.coordination.http.endpoints.FlowAnalysisRuleEndpointMerger;
 import 
org.apache.nifi.cluster.coordination.http.endpoints.FlowAnalysisRuleTypesEndpointMerger;
@@ -161,6 +162,7 @@ public class StandardHttpResponseMapper implements 
HttpResponseMapper {
         endpointMergers.add(new RemoteProcessGroupsEndpointMerger());
         endpointMergers.add(new ProcessGroupEndpointMerger());
         endpointMergers.add(new ProcessGroupsEndpointMerger());
+        endpointMergers.add(new ExportProcessGroupEndpointMerger());
         endpointMergers.add(new FlowSnippetEndpointMerger());
         endpointMergers.add(new PasteEndpointMerger());
         endpointMergers.add(new ProvenanceQueryEndpointMerger());
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportProcessGroupEndpointMerger.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportProcessGroupEndpointMerger.java
new file mode 100644
index 00000000000..22fdd751fba
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportProcessGroupEndpointMerger.java
@@ -0,0 +1,156 @@
+/*
+ * 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.cluster.coordination.http.endpoints;
+
+import org.apache.nifi.cluster.coordination.http.EndpointResponseMerger;
+import org.apache.nifi.cluster.manager.NodeResponse;
+import org.apache.nifi.flow.VersionedComponentState;
+import org.apache.nifi.flow.VersionedConfigurableExtension;
+import org.apache.nifi.flow.VersionedControllerService;
+import org.apache.nifi.flow.VersionedNodeState;
+import org.apache.nifi.flow.VersionedProcessGroup;
+import org.apache.nifi.flow.VersionedProcessor;
+import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Merges responses for {@code GET /nifi-api/process-groups/{uuid}/download}.
+ * When component state is included in the export, each cluster node 
contributes its own LOCAL state
+ * keyed by ordinal position. This merger combines the {@code localNodeStates} 
maps from every node's
+ * snapshot into a single response.
+ */
+public class ExportProcessGroupEndpointMerger implements 
EndpointResponseMerger {
+
+    public static final Pattern PROCESS_GROUP_DOWNLOAD_URI_PATTERN =
+            
Pattern.compile("/nifi-api/process-groups/(?:(?:root)|(?:[a-f0-9\\-]{36}))/download");
+
+    @Override
+    public boolean canHandle(final URI uri, final String method) {
+        return "GET".equalsIgnoreCase(method) && 
PROCESS_GROUP_DOWNLOAD_URI_PATTERN.matcher(uri.getPath()).matches();
+    }
+
+    @Override
+    public NodeResponse merge(final URI uri, final String method, final 
Set<NodeResponse> successfulResponses, final Set<NodeResponse> 
problematicResponses, final NodeResponse clientResponse) {
+        if (!canHandle(uri, method)) {
+            throw new IllegalArgumentException("Cannot use Endpoint Mapper of 
type " + getClass().getSimpleName() + " to map responses for URI " + uri + ", 
HTTP Method " + method);
+        }
+
+        final RegisteredFlowSnapshot mergedSnapshot = 
clientResponse.getClientResponse().readEntity(RegisteredFlowSnapshot.class);
+        for (final NodeResponse nodeResponse : successfulResponses) {
+            if (nodeResponse == clientResponse) {
+                continue;
+            }
+            final RegisteredFlowSnapshot nodeSnapshot = 
nodeResponse.getClientResponse().readEntity(RegisteredFlowSnapshot.class);
+            mergeLocalNodeStates(mergedSnapshot.getFlowContents(), 
nodeSnapshot.getFlowContents());
+        }
+
+        return new NodeResponse(clientResponse, new 
ExportedFlowSnapshotEntity(mergedSnapshot));
+    }
+
+    /**
+     * Recursively merges localNodeStates from a source process group into a 
target process group.
+     * For each stateful component (processor or controller service), the 
LOCAL state entries from
+     * the source are added to the target's localNodeStates list. Cluster 
state is identical across
+     * nodes and is already present in the target.
+     *
+     * @param target the process group to merge into
+     * @param source the process group to merge from
+     */
+    private void mergeLocalNodeStates(final VersionedProcessGroup target, 
final VersionedProcessGroup source) {
+        if (target == null || source == null) {
+            return;
+        }
+
+        if (target.getProcessors() != null && source.getProcessors() != null) {
+            final Map<String, VersionedProcessor> sourceProcessors = new 
HashMap<>();
+            for (final VersionedProcessor sp : source.getProcessors()) {
+                sourceProcessors.put(sp.getIdentifier(), sp);
+            }
+            for (final VersionedProcessor tp : target.getProcessors()) {
+                mergeComponentState(tp, 
sourceProcessors.get(tp.getIdentifier()));
+            }
+        }
+
+        if (target.getControllerServices() != null && 
source.getControllerServices() != null) {
+            final Map<String, VersionedControllerService> sourceServices = new 
HashMap<>();
+            for (final VersionedControllerService ss : 
source.getControllerServices()) {
+                sourceServices.put(ss.getIdentifier(), ss);
+            }
+            for (final VersionedControllerService ts : 
target.getControllerServices()) {
+                mergeComponentState(ts, 
sourceServices.get(ts.getIdentifier()));
+            }
+        }
+
+        if (target.getProcessGroups() != null && source.getProcessGroups() != 
null) {
+            final Map<String, VersionedProcessGroup> sourceGroups = new 
HashMap<>();
+            for (final VersionedProcessGroup sg : source.getProcessGroups()) {
+                sourceGroups.put(sg.getIdentifier(), sg);
+            }
+            for (final VersionedProcessGroup tg : target.getProcessGroups()) {
+                mergeLocalNodeStates(tg, sourceGroups.get(tg.getIdentifier()));
+            }
+        }
+    }
+
+    /**
+     * Merges localNodeStates from a source component into a target component. 
Each node contributes its
+     * own ordinal entry and the merge fills in any missing slots in the 
target's list.
+     *
+     * @param target the target component (already contains state from first 
node)
+     * @param source the source component (contains state from another node), 
may be {@code null}
+     */
+    private void mergeComponentState(final VersionedConfigurableExtension 
target, final VersionedConfigurableExtension source) {
+        if (source == null) {
+            return;
+        }
+
+        final VersionedComponentState sourceState = source.getComponentState();
+        if (sourceState == null || sourceState.getLocalNodeStates() == null) {
+            return;
+        }
+
+        VersionedComponentState targetState = target.getComponentState();
+        if (targetState == null) {
+            targetState = new VersionedComponentState();
+            target.setComponentState(targetState);
+        }
+
+        final List<VersionedNodeState> sourceList = 
sourceState.getLocalNodeStates();
+        if (targetState.getLocalNodeStates() == null) {
+            targetState.setLocalNodeStates(new ArrayList<>(sourceList));
+            return;
+        }
+
+        final List<VersionedNodeState> targetList = 
targetState.getLocalNodeStates();
+        while (targetList.size() < sourceList.size()) {
+            targetList.add(null);
+        }
+        for (int i = 0; i < sourceList.size(); i++) {
+            if (sourceList.get(i) != null) {
+                targetList.set(i, sourceList.get(i));
+            }
+        }
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportedFlowSnapshotEntity.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportedFlowSnapshotEntity.java
new file mode 100644
index 00000000000..e3496fd2030
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportedFlowSnapshotEntity.java
@@ -0,0 +1,91 @@
+/*
+ * 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.cluster.coordination.http.endpoints;
+
+import jakarta.xml.bind.annotation.XmlTransient;
+import org.apache.nifi.flow.ExternalControllerServiceReference;
+import org.apache.nifi.flow.ParameterProviderReference;
+import org.apache.nifi.flow.VersionedParameterContext;
+import org.apache.nifi.flow.VersionedProcessGroup;
+import org.apache.nifi.registry.flow.FlowRegistryBucket;
+import org.apache.nifi.registry.flow.RegisteredFlow;
+import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
+import org.apache.nifi.registry.flow.RegisteredFlowSnapshotMetadata;
+import org.apache.nifi.web.api.entity.Entity;
+
+import java.util.Map;
+
+/**
+ * Internal {@link Entity} wrapper that allows {@link 
ExportProcessGroupEndpointMerger} to participate in the
+ * {@code NodeResponse(NodeResponse, Entity)} merging contract while 
preserving the on-the-wire JSON format of
+ * {@code GET /process-groups/{id}/download}.
+ *
+ * <p>NiFi's web layer configures Jackson with the JAXB annotation 
introspector only, so Jackson-only mechanisms
+ * such as {@code @JsonValue} or {@code @JsonSerialize} are not honored. 
Instead, every property of the wrapped
+ * {@link RegisteredFlowSnapshot} is exposed via a delegating getter so JAXB 
property discovery produces the same
+ * JSON structure as serializing a {@code RegisteredFlowSnapshot} directly.
+ */
+class ExportedFlowSnapshotEntity extends Entity {
+
+    private final RegisteredFlowSnapshot snapshot;
+
+    ExportedFlowSnapshotEntity(final RegisteredFlowSnapshot snapshot) {
+        this.snapshot = snapshot;
+    }
+
+    @XmlTransient
+    public RegisteredFlowSnapshot getSnapshot() {
+        return snapshot;
+    }
+
+    public RegisteredFlowSnapshotMetadata getSnapshotMetadata() {
+        return snapshot.getSnapshotMetadata();
+    }
+
+    public RegisteredFlow getFlow() {
+        return snapshot.getFlow();
+    }
+
+    public FlowRegistryBucket getBucket() {
+        return snapshot.getBucket();
+    }
+
+    public VersionedProcessGroup getFlowContents() {
+        return snapshot.getFlowContents();
+    }
+
+    public Map<String, ExternalControllerServiceReference> 
getExternalControllerServices() {
+        return snapshot.getExternalControllerServices();
+    }
+
+    public Map<String, VersionedParameterContext> getParameterContexts() {
+        return snapshot.getParameterContexts();
+    }
+
+    public String getFlowEncodingVersion() {
+        return snapshot.getFlowEncodingVersion();
+    }
+
+    public Map<String, ParameterProviderReference> getParameterProviders() {
+        return snapshot.getParameterProviders();
+    }
+
+    public boolean isLatest() {
+        return snapshot.isLatest();
+    }
+}
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 264e2830ae9..e3704ead9ad 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
@@ -17,10 +17,15 @@
 
 package org.apache.nifi.flow.synchronization;
 
+import org.apache.nifi.annotation.behavior.Stateful;
 import org.apache.nifi.asset.Asset;
 import org.apache.nifi.asset.AssetManager;
 import org.apache.nifi.bundle.BundleCoordinate;
+import org.apache.nifi.components.ConfigurableComponent;
 import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.components.state.StateManager;
+import org.apache.nifi.components.state.StateManagerProvider;
 import org.apache.nifi.connectable.Connectable;
 import org.apache.nifi.connectable.ConnectableType;
 import org.apache.nifi.connectable.Connection;
@@ -57,6 +62,8 @@ import org.apache.nifi.flow.ExecutionEngine;
 import org.apache.nifi.flow.ParameterProviderReference;
 import org.apache.nifi.flow.VersionedAsset;
 import org.apache.nifi.flow.VersionedComponent;
+import org.apache.nifi.flow.VersionedComponentState;
+import org.apache.nifi.flow.VersionedConfigurableExtension;
 import org.apache.nifi.flow.VersionedConnection;
 import org.apache.nifi.flow.VersionedControllerService;
 import org.apache.nifi.flow.VersionedExternalFlow;
@@ -64,6 +71,7 @@ import org.apache.nifi.flow.VersionedFlowAnalysisRule;
 import org.apache.nifi.flow.VersionedFlowCoordinates;
 import org.apache.nifi.flow.VersionedFunnel;
 import org.apache.nifi.flow.VersionedLabel;
+import org.apache.nifi.flow.VersionedNodeState;
 import org.apache.nifi.flow.VersionedParameter;
 import org.apache.nifi.flow.VersionedParameterContext;
 import org.apache.nifi.flow.VersionedPort;
@@ -125,6 +133,8 @@ import org.apache.nifi.util.FlowDifferenceFilters;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.net.URL;
 import java.time.Duration;
 import java.util.ArrayList;
@@ -327,6 +337,8 @@ public class StandardVersionedComponentSynchronizer 
implements VersionedComponen
         createdAndModifiedExtensions.clear();
         setSynchronizationOptions(options);
 
+        validateLocalStateTopology(versionedExternalFlow.getFlowContents());
+
         for (final FlowDifference diff : flowComparison.getDifferences()) {
             if (!FlowDifferenceFilters.isComponentUpdateRequired(diff, 
versionedExternalFlow.getFlowContents(), context.getFlowManager())) {
                 continue;
@@ -1439,6 +1451,8 @@ public class StandardVersionedComponentSynchronizer 
implements VersionedComponen
 
         updateControllerService(newService, proposed, topLevelGroup);
 
+        restoreComponentState(newService.getIdentifier(), 
proposed.getComponentState(), newService);
+
         return newService;
     }
 
@@ -2749,6 +2763,8 @@ public class StandardVersionedComponentSynchronizer 
implements VersionedComponen
         procNode.onConfigurationRestored(processContext);
         connectableAdditionTracker.addComponent(destination.getIdentifier(), 
proposed.getIdentifier(), procNode);
 
+        restoreComponentState(procNode.getIdentifier(), 
proposed.getComponentState(), procNode);
+
         return procNode;
     }
 
@@ -4080,6 +4096,104 @@ public class StandardVersionedComponentSynchronizer 
implements VersionedComponen
         return propertyValues;
     }
 
+    private void validateLocalStateTopology(final VersionedProcessGroup 
proposed) {
+        final int connectedNodeCount = context.getConnectedNodeCount();
+        if (connectedNodeCount <= 0) {
+            return;
+        }
+
+        final int maxSourceNodes = findMaxLocalStateNodeCount(proposed);
+        if (maxSourceNodes > connectedNodeCount) {
+            throw new IllegalStateException(
+                    "Cannot import flow with component state: the flow 
definition contains local state from %d source node(s) but the destination 
cluster has only %d connected node(s). "
+                            .formatted(maxSourceNodes, connectedNodeCount)
+                    + "Import into a cluster with at least %d node(s), or 
export without component state.".formatted(maxSourceNodes));
+        }
+    }
+
+    private int findMaxLocalStateNodeCount(final VersionedProcessGroup group) {
+        int max = 0;
+        for (final VersionedConfigurableExtension ext : 
getStatefulExtensions(group)) {
+            final VersionedComponentState state = ext.getComponentState();
+            if (state != null && state.getLocalNodeStates() != null) {
+                max = Math.max(max, state.getLocalNodeStates().size());
+            }
+        }
+        if (group.getProcessGroups() != null) {
+            for (final VersionedProcessGroup child : group.getProcessGroups()) 
{
+                max = Math.max(max, findMaxLocalStateNodeCount(child));
+            }
+        }
+        return max;
+    }
+
+    private List<VersionedConfigurableExtension> getStatefulExtensions(final 
VersionedProcessGroup group) {
+        final List<VersionedConfigurableExtension> extensions = new 
ArrayList<>();
+        if (group.getProcessors() != null) {
+            extensions.addAll(group.getProcessors());
+        }
+        if (group.getControllerServices() != null) {
+            extensions.addAll(group.getControllerServices());
+        }
+        return extensions;
+    }
+
+    private void restoreComponentState(final String componentId, final 
VersionedComponentState componentState, final ComponentNode componentNode) {
+        if (componentState == null) {
+            return;
+        }
+
+        final StateManagerProvider stateManagerProvider = 
context.getStateManagerProvider();
+        if (stateManagerProvider == null) {
+            LOG.warn("StateManagerProvider is not available; component state 
from the flow snapshot will not be restored for component {}", componentId);
+            return;
+        }
+
+        final ConfigurableComponent component = componentNode.getComponent();
+        if (component == null) {
+            LOG.warn("Component {} is not available; component state from the 
flow snapshot will not be restored", componentId);
+            return;
+        }
+
+        final Stateful stateful = 
component.getClass().getAnnotation(Stateful.class);
+        if (stateful == null) {
+            LOG.warn("Component {} ({}) is not annotated with @Stateful; 
component state from the flow snapshot will not be restored",
+                    componentId, component.getClass().getSimpleName());
+            return;
+        }
+
+        final Set<Scope> supportedScopes = Set.of(stateful.scopes());
+        final StateManager stateManager = 
stateManagerProvider.getStateManager(componentId);
+
+        try {
+            if (supportedScopes.contains(Scope.CLUSTER) && 
componentState.getClusterState() != null && 
!componentState.getClusterState().isEmpty()) {
+                stateManager.setState(componentState.getClusterState(), 
Scope.CLUSTER);
+                LOG.debug("Restored cluster state for component {}", 
componentId);
+            }
+
+            if (supportedScopes.contains(Scope.LOCAL) && 
componentState.getLocalNodeStates() != null && 
!componentState.getLocalNodeStates().isEmpty()) {
+                final int localNodeOrdinal = context.getLocalNodeOrdinal();
+                if (localNodeOrdinal < 0) {
+                    LOG.warn("Local node ordinal is not set; local component 
state from the flow snapshot will not be restored for component {}", 
componentId);
+                    return;
+                }
+
+                final List<VersionedNodeState> localNodeStates = 
componentState.getLocalNodeStates();
+                final VersionedNodeState nodeState = localNodeOrdinal < 
localNodeStates.size() ? localNodeStates.get(localNodeOrdinal) : null;
+                final Map<String, String> localState = nodeState != null ? 
nodeState.getState() : null;
+                if (localState != null && !localState.isEmpty()) {
+                    stateManager.setState(localState, Scope.LOCAL);
+                    LOG.debug("Restored local state for component {} from node 
ordinal {}", componentId, localNodeOrdinal);
+                } else {
+                    LOG.info("No local component state was captured for node 
ordinal {} in the flow snapshot; local state for component {} will not be 
restored on this node",
+                            localNodeOrdinal, componentId);
+                }
+            }
+        } catch (final IOException e) {
+            throw new UncheckedIOException("Failed to restore state for 
component %s".formatted(componentId), e);
+        }
+    }
+
     private record CreatedOrModifiedExtension(ComponentNode extension, 
Map<String, String> propertyValues) {
     }
 
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/VersionedFlowSynchronizationContext.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/VersionedFlowSynchronizationContext.java
index f0b2183b360..0769e304993 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/VersionedFlowSynchronizationContext.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/VersionedFlowSynchronizationContext.java
@@ -18,6 +18,7 @@
 package org.apache.nifi.flow.synchronization;
 
 import org.apache.nifi.asset.AssetManager;
+import org.apache.nifi.components.state.StateManagerProvider;
 import org.apache.nifi.controller.ComponentNode;
 import org.apache.nifi.controller.ConfigurationContext;
 import org.apache.nifi.controller.ProcessorNode;
@@ -45,6 +46,9 @@ public class VersionedFlowSynchronizationContext {
     private final Function<ProcessorNode, ProcessContext> 
processContextFactory;
     private final Function<ComponentNode, ConfigurationContext> 
configurationContextFactory;
     private final AssetManager assetManager;
+    private final StateManagerProvider stateManagerProvider;
+    private final int localNodeOrdinal;
+    private final int connectedNodeCount;
 
     private VersionedFlowSynchronizationContext(final Builder builder) {
         this.componentIdGenerator = builder.componentIdGenerator;
@@ -57,6 +61,9 @@ public class VersionedFlowSynchronizationContext {
         this.processContextFactory = builder.processContextFactory;
         this.configurationContextFactory = builder.configurationContextFactory;
         this.assetManager = builder.assetManager;
+        this.stateManagerProvider = builder.stateManagerProvider;
+        this.localNodeOrdinal = builder.localNodeOrdinal;
+        this.connectedNodeCount = builder.connectedNodeCount;
     }
 
     public ComponentIdGenerator getComponentIdGenerator() {
@@ -99,6 +106,18 @@ public class VersionedFlowSynchronizationContext {
         return assetManager;
     }
 
+    public StateManagerProvider getStateManagerProvider() {
+        return stateManagerProvider;
+    }
+
+    public int getLocalNodeOrdinal() {
+        return localNodeOrdinal;
+    }
+
+    public int getConnectedNodeCount() {
+        return connectedNodeCount;
+    }
+
     public static class Builder {
         private ComponentIdGenerator componentIdGenerator;
         private FlowManager flowManager;
@@ -110,6 +129,9 @@ public class VersionedFlowSynchronizationContext {
         private Function<ProcessorNode, ProcessContext> processContextFactory;
         private Function<ComponentNode, ConfigurationContext> 
configurationContextFactory;
         private AssetManager assetManager;
+        private StateManagerProvider stateManagerProvider;
+        private int localNodeOrdinal = -1;
+        private int connectedNodeCount = -1;
 
         public Builder componentIdGenerator(final ComponentIdGenerator 
componentIdGenerator) {
             this.componentIdGenerator = componentIdGenerator;
@@ -161,6 +183,21 @@ public class VersionedFlowSynchronizationContext {
             return this;
         }
 
+        public Builder stateManagerProvider(final StateManagerProvider 
stateManagerProvider) {
+            this.stateManagerProvider = stateManagerProvider;
+            return this;
+        }
+
+        public Builder localNodeOrdinal(final int localNodeOrdinal) {
+            this.localNodeOrdinal = localNodeOrdinal;
+            return this;
+        }
+
+        public Builder connectedNodeCount(final int connectedNodeCount) {
+            this.connectedNodeCount = connectedNodeCount;
+            return this;
+        }
+
         public VersionedFlowSynchronizationContext build() {
             requireNonNull(componentIdGenerator, "Component ID Generator must 
be set");
             requireNonNull(flowManager, "Flow Manager must be set");
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/StandardProcessGroup.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/StandardProcessGroup.java
index 9f51aa82f0e..755f48f93a7 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/StandardProcessGroup.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/StandardProcessGroup.java
@@ -41,6 +41,7 @@ import org.apache.nifi.connectable.Port;
 import org.apache.nifi.connectable.Position;
 import org.apache.nifi.connectable.Positionable;
 import org.apache.nifi.connectable.ProcessGroupFlowFileActivity;
+import org.apache.nifi.controller.ClusterTopologyProvider;
 import org.apache.nifi.controller.ComponentNode;
 import org.apache.nifi.controller.ConfigurationContext;
 import org.apache.nifi.controller.ControllerService;
@@ -195,6 +196,7 @@ public final class StandardProcessGroup implements 
ProcessGroup {
     private final VersionControlFields versionControlFields = new 
VersionControlFields();
     private volatile ParameterContext parameterContext;
     private final NodeTypeProvider nodeTypeProvider;
+    private final ClusterTopologyProvider clusterTopologyProvider;
     private final AssetManager assetManager;
     private final StatelessGroupNode statelessGroupNode;
     private volatile ExecutionEngine executionEngine = 
ExecutionEngine.INHERITED;
@@ -232,6 +234,7 @@ public final class StandardProcessGroup implements 
ProcessGroup {
                                 final PropertyEncryptor encryptor, final 
ExtensionManager extensionManager,
                                 final StateManagerProvider 
stateManagerProvider, final FlowManager flowManager,
                                 final ReloadComponent reloadComponent, final 
NodeTypeProvider nodeTypeProvider,
+                                final ClusterTopologyProvider 
clusterTopologyProvider,
                                 final NiFiProperties nifiProperties, final 
StatelessGroupNodeFactory statelessGroupNodeFactory,
                                 final AssetManager assetManager, final String 
connectorId) {
 
@@ -246,6 +249,7 @@ public final class StandardProcessGroup implements 
ProcessGroup {
         this.flowManager = flowManager;
         this.reloadComponent = reloadComponent;
         this.nodeTypeProvider = nodeTypeProvider;
+        this.clusterTopologyProvider = clusterTopologyProvider;
         this.assetManager = assetManager;
         this.connectorId = connectorId;
 
@@ -4073,6 +4077,9 @@ public final class StandardProcessGroup implements 
ProcessGroup {
 
     private VersionedFlowSynchronizationContext 
createGroupSynchronizationContext(final ComponentIdGenerator 
componentIdGenerator, final ComponentScheduler componentScheduler,
                                                                                
   final FlowMappingOptions flowMappingOptions) {
+        final int localNodeOrdinal = 
clusterTopologyProvider.getLocalNodeOrdinal();
+        final int connectedNodeCount = 
clusterTopologyProvider.getConnectedNodeCount();
+
         return new VersionedFlowSynchronizationContext.Builder()
             .componentIdGenerator(componentIdGenerator)
             .flowManager(flowManager)
@@ -4084,6 +4091,9 @@ public final class StandardProcessGroup implements 
ProcessGroup {
             .processContextFactory(this::createProcessContext)
             .configurationContextFactory(this::createConfigurationContext)
             .assetManager(assetManager)
+            .stateManagerProvider(stateManagerProvider)
+            .localNodeOrdinal(localNodeOrdinal)
+            .connectedNodeCount(connectedNodeCount)
             .build();
     }
 
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/registry/flow/mapping/VersionedComponentFlowMapper.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/registry/flow/mapping/VersionedComponentFlowMapper.java
index 1d845265c9b..216e4fd7642 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/registry/flow/mapping/VersionedComponentFlowMapper.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/registry/flow/mapping/VersionedComponentFlowMapper.java
@@ -18,8 +18,10 @@
 package org.apache.nifi.registry.flow.mapping;
 
 import org.apache.commons.lang3.ClassUtils;
+import org.apache.nifi.annotation.behavior.Stateful;
 import org.apache.nifi.asset.Asset;
 import org.apache.nifi.bundle.BundleCoordinate;
+import org.apache.nifi.components.ConfigurableComponent;
 import org.apache.nifi.components.PropertyDescriptor;
 import org.apache.nifi.components.connector.AssetReference;
 import org.apache.nifi.components.connector.ConnectorConfiguration;
@@ -34,6 +36,10 @@ import 
org.apache.nifi.components.connector.StringLiteralValue;
 import org.apache.nifi.components.listen.ListenPortDefinition;
 import org.apache.nifi.components.resource.ResourceCardinality;
 import org.apache.nifi.components.resource.ResourceDefinition;
+import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.components.state.StateManager;
+import org.apache.nifi.components.state.StateManagerProvider;
+import org.apache.nifi.components.state.StateMap;
 import org.apache.nifi.connectable.Connectable;
 import org.apache.nifi.connectable.Connection;
 import org.apache.nifi.connectable.Funnel;
@@ -63,6 +69,7 @@ import org.apache.nifi.flow.ParameterProviderReference;
 import org.apache.nifi.flow.PortType;
 import org.apache.nifi.flow.Position;
 import org.apache.nifi.flow.VersionedAsset;
+import org.apache.nifi.flow.VersionedComponentState;
 import org.apache.nifi.flow.VersionedConfigurationStep;
 import org.apache.nifi.flow.VersionedConnection;
 import org.apache.nifi.flow.VersionedConnector;
@@ -74,6 +81,7 @@ import org.apache.nifi.flow.VersionedFlowRegistryClient;
 import org.apache.nifi.flow.VersionedFunnel;
 import org.apache.nifi.flow.VersionedLabel;
 import org.apache.nifi.flow.VersionedListenPortDefinition;
+import org.apache.nifi.flow.VersionedNodeState;
 import org.apache.nifi.flow.VersionedParameter;
 import org.apache.nifi.flow.VersionedParameterContext;
 import org.apache.nifi.flow.VersionedParameterProvider;
@@ -105,6 +113,8 @@ import 
org.apache.nifi.registry.flow.VersionControlInformation;
 import org.apache.nifi.remote.PublicPort;
 import org.apache.nifi.remote.RemoteGroupPort;
 
+import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -518,10 +528,60 @@ public class VersionedComponentFlowMapper {
         
versionedService.setPropertyDescriptors(mapPropertyDescriptors(controllerService,
 serviceProvider, includedGroupIds, externalControllerServiceReferences));
         versionedService.setType(controllerService.getCanonicalClassName());
         
versionedService.setScheduledState(flowMappingOptions.getStateLookup().getState(controllerService));
+        
versionedService.setComponentState(mapComponentState(controllerService));
 
         return versionedService;
     }
 
+    private VersionedComponentState mapComponentState(final ComponentNode 
componentNode) {
+        if (!flowMappingOptions.isMapComponentState()) {
+            return null;
+        }
+
+        final ConfigurableComponent component = componentNode.getComponent();
+        if (component == null) {
+            return null;
+        }
+
+        final Stateful stateful = 
component.getClass().getAnnotation(Stateful.class);
+        if (stateful == null) {
+            return null;
+        }
+
+        final String componentId = componentNode.getIdentifier();
+        final StateManagerProvider stateManagerProvider = 
flowMappingOptions.getStateManagerProvider();
+        final StateManager stateManager = 
stateManagerProvider.getStateManager(componentId);
+        final boolean clusterProviderEnabled = 
stateManagerProvider.isClusterProviderEnabled();
+        final VersionedComponentState result = new VersionedComponentState();
+        boolean hasState = false;
+
+        try {
+            for (final Scope scope : stateful.scopes()) {
+                if (scope == Scope.CLUSTER && !clusterProviderEnabled) {
+                    continue;
+                }
+
+                final StateMap stateMap = stateManager.getState(scope);
+                if (stateMap != null && !stateMap.toMap().isEmpty()) {
+                    if (scope == Scope.CLUSTER) {
+                        result.setClusterState(stateMap.toMap());
+                        hasState = true;
+                    } else if (scope == Scope.LOCAL) {
+                        final int ordinal = 
flowMappingOptions.getLocalNodeOrdinal();
+                        final List<VersionedNodeState> localStates = new 
ArrayList<>(Collections.nCopies(ordinal + 1, null));
+                        localStates.set(ordinal, new 
VersionedNodeState(stateMap.toMap()));
+                        result.setLocalNodeStates(localStates);
+                        hasState = true;
+                    }
+                }
+            }
+        } catch (final IOException e) {
+            throw new UncheckedIOException("Failed to retrieve state for 
component %s".formatted(componentId), e);
+        }
+
+        return hasState ? result : null;
+    }
+
     private Map<String, String> mapProperties(final ComponentNode component, 
final ControllerServiceProvider serviceProvider) {
         final Map<String, String> mapped = new HashMap<>();
 
@@ -787,6 +847,7 @@ public class VersionedComponentFlowMapper {
         processor.setRetriedRelationships(procNode.getRetriedRelationships());
         processor.setBackoffMechanism(procNode.getBackoffMechanism().name());
         processor.setMaxBackoffPeriod(procNode.getMaxBackoffPeriod());
+        processor.setComponentState(mapComponentState(procNode));
 
         return processor;
     }
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/groups/StandardProcessGroupTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/groups/StandardProcessGroupTest.java
index 3a57a120b8e..35051a30f9f 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/groups/StandardProcessGroupTest.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/groups/StandardProcessGroupTest.java
@@ -21,6 +21,7 @@ import org.apache.nifi.components.state.Scope;
 import org.apache.nifi.components.state.StateManager;
 import org.apache.nifi.components.state.StateManagerProvider;
 import org.apache.nifi.components.state.StateMap;
+import org.apache.nifi.controller.ClusterTopologyProvider;
 import org.apache.nifi.controller.NodeTypeProvider;
 import org.apache.nifi.controller.ProcessScheduler;
 import org.apache.nifi.controller.ReloadComponent;
@@ -89,6 +90,9 @@ class StandardProcessGroupTest {
     @Mock
     private NodeTypeProvider nodeTypeProvider;
 
+    @Mock
+    private ClusterTopologyProvider clusterTopologyProvider;
+
     @Mock
     private NiFiProperties properties;
 
@@ -130,6 +134,7 @@ class StandardProcessGroupTest {
                 flowManager,
                 reloadComponent,
                 nodeTypeProvider,
+                clusterTopologyProvider,
                 properties,
                 statelessGroupNodeFactory,
                 assetManager,
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/registry/flow/mapping/TestVersionedComponentFlowMapper.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/registry/flow/mapping/TestVersionedComponentFlowMapper.java
index cf263592778..9b0677ea35a 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/registry/flow/mapping/TestVersionedComponentFlowMapper.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/registry/flow/mapping/TestVersionedComponentFlowMapper.java
@@ -17,10 +17,14 @@
 
 package org.apache.nifi.registry.flow.mapping;
 
+import org.apache.nifi.annotation.behavior.Stateful;
 import org.apache.nifi.attribute.expression.language.VariableImpact;
 import org.apache.nifi.bundle.BundleCoordinate;
 import org.apache.nifi.components.PropertyDescriptor;
 import org.apache.nifi.components.Validator;
+import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.components.state.StateManager;
+import org.apache.nifi.components.state.StateManagerProvider;
 import org.apache.nifi.connectable.Position;
 import org.apache.nifi.controller.BackoffMechanism;
 import org.apache.nifi.controller.ControllerService;
@@ -29,17 +33,25 @@ import org.apache.nifi.controller.PropertyConfiguration;
 import org.apache.nifi.controller.ScheduledState;
 import org.apache.nifi.controller.service.ControllerServiceNode;
 import org.apache.nifi.controller.service.ControllerServiceProvider;
+import org.apache.nifi.controller.state.StandardStateMap;
+import org.apache.nifi.flow.VersionedComponentState;
+import org.apache.nifi.flow.VersionedNodeState;
 import org.apache.nifi.flow.VersionedProcessor;
 import org.apache.nifi.logging.LogLevel;
 import org.apache.nifi.nar.ExtensionManager;
 import org.apache.nifi.parameter.ExpressionLanguageAgnosticParameterParser;
 import org.apache.nifi.parameter.ParameterReference;
 import org.apache.nifi.parameter.ParameterTokenList;
+import org.apache.nifi.processor.AbstractProcessor;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.exception.ProcessException;
 import org.apache.nifi.scheduling.ExecutionNode;
 import org.apache.nifi.scheduling.SchedulingStrategy;
 import org.junit.jupiter.api.Test;
 import org.mockito.stubbing.Answer;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -166,6 +178,100 @@ public class TestVersionedComponentFlowMapper {
         }
     }
 
+    @Test
+    public void testComponentStateNotMappedWhenDisabled() {
+        final ExtensionManager extensionManager = mock(ExtensionManager.class);
+        final FlowMappingOptions mappingOptions = 
FlowMappingOptions.DEFAULT_OPTIONS;
+        final VersionedComponentFlowMapper mapper = new 
VersionedComponentFlowMapper(extensionManager, mappingOptions);
+
+        final ProcessorNode procNode = createStatefulProcessorNode();
+
+        final VersionedProcessor versionedProcessor = 
mapper.mapProcessor(procNode, mock(ControllerServiceProvider.class),
+                Collections.emptySet(), Collections.emptyMap());
+        assertNotNull(versionedProcessor);
+        assertNull(versionedProcessor.getComponentState());
+    }
+
+    @Test
+    public void 
testStandaloneExportSkipsClusterStateForProcessorWithBothScopes() throws 
IOException {
+        final Map<String, String> localState = Map.of("wal.position", "0/42");
+        final Map<String, String> clusterState = Map.of("wal.position", 
"0/42");
+
+        final ProcessorNode procNode = createStatefulProcessorNode();
+        final FlowMappingOptions mappingOptions = 
buildComponentStateMappingOptions(procNode, localState, clusterState, false, 0);
+
+        final VersionedComponentFlowMapper mapper = new 
VersionedComponentFlowMapper(mock(ExtensionManager.class), mappingOptions);
+        final VersionedProcessor versionedProcessor = 
mapper.mapProcessor(procNode, mock(ControllerServiceProvider.class),
+                Collections.emptySet(), Collections.emptyMap());
+
+        assertNotNull(versionedProcessor);
+        final VersionedComponentState mappedState = 
versionedProcessor.getComponentState();
+        assertNotNull(mappedState);
+        assertNull(mappedState.getClusterState());
+
+        final List<VersionedNodeState> localNodeStates = 
mappedState.getLocalNodeStates();
+        assertNotNull(localNodeStates);
+        assertEquals(1, localNodeStates.size());
+        assertEquals(localState, localNodeStates.get(0).getState());
+    }
+
+    @Test
+    public void testClusteredExportCapturesBothLocalAndClusterState() throws 
IOException {
+        final Map<String, String> localState = Map.of("local.key", 
"local-value");
+        final Map<String, String> clusterState = Map.of("cluster.key", 
"cluster-value");
+
+        final ProcessorNode procNode = createStatefulProcessorNode();
+        final FlowMappingOptions mappingOptions = 
buildComponentStateMappingOptions(procNode, localState, clusterState, true, 1);
+
+        final VersionedComponentFlowMapper mapper = new 
VersionedComponentFlowMapper(mock(ExtensionManager.class), mappingOptions);
+        final VersionedProcessor versionedProcessor = 
mapper.mapProcessor(procNode, mock(ControllerServiceProvider.class),
+                Collections.emptySet(), Collections.emptyMap());
+
+        assertNotNull(versionedProcessor);
+        final VersionedComponentState mappedState = 
versionedProcessor.getComponentState();
+        assertNotNull(mappedState);
+        assertEquals(clusterState, mappedState.getClusterState());
+
+        final List<VersionedNodeState> localNodeStates = 
mappedState.getLocalNodeStates();
+        assertNotNull(localNodeStates);
+        assertEquals(2, localNodeStates.size());
+        assertNull(localNodeStates.get(0));
+        assertEquals(localState, localNodeStates.get(1).getState());
+    }
+
+    private FlowMappingOptions buildComponentStateMappingOptions(final 
ProcessorNode procNode, final Map<String, String> localState,
+                                                                 final 
Map<String, String> clusterState, final boolean clusterProviderEnabled,
+                                                                 final int 
localNodeOrdinal) throws IOException {
+        final StateManager stateManager = mock(StateManager.class);
+        when(stateManager.getState(Scope.LOCAL)).thenReturn(new 
StandardStateMap(localState, Optional.empty()));
+        when(stateManager.getState(Scope.CLUSTER)).thenReturn(new 
StandardStateMap(clusterState, Optional.empty()));
+
+        final StateManagerProvider stateManagerProvider = 
mock(StateManagerProvider.class);
+        
when(stateManagerProvider.isClusterProviderEnabled()).thenReturn(clusterProviderEnabled);
+        
when(stateManagerProvider.getStateManager(procNode.getIdentifier())).thenReturn(stateManager);
+
+        return new FlowMappingOptions.Builder()
+                .stateLookup(VersionedComponentStateLookup.IDENTITY_LOOKUP)
+                .componentIdLookup(ComponentIdLookup.USE_COMPONENT_ID)
+                .mapComponentState(true)
+                .stateManagerProvider(stateManagerProvider)
+                .localNodeOrdinal(localNodeOrdinal)
+                .build();
+    }
+
+    private ProcessorNode createStatefulProcessorNode() {
+        final ProcessorNode procNode = createProcessorNode(new HashMap<>());
+        when(procNode.getComponent()).thenReturn(new 
StatefulLocalAndClusterProcessor());
+        return procNode;
+    }
+
+    @Stateful(scopes = {Scope.LOCAL, Scope.CLUSTER}, description = "Test 
processor with both local and cluster state scopes")
+    private static class StatefulLocalAndClusterProcessor extends 
AbstractProcessor {
+        @Override
+        public void onTrigger(final ProcessContext context, final 
ProcessSession session) throws ProcessException {
+        }
+    }
+
     private ProcessorNode createProcessorNode(final Map<String, String> 
properties) {
         final ExpressionLanguageAgnosticParameterParser parameterParser = new 
ExpressionLanguageAgnosticParameterParser();
 
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/controller/ClusterTopologyProvider.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/controller/ClusterTopologyProvider.java
new file mode 100644
index 00000000000..d85662cb7c9
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/controller/ClusterTopologyProvider.java
@@ -0,0 +1,40 @@
+/*
+ * 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.controller;
+
+/**
+ * Framework-internal helper that exposes connected, port-disambiguated 
cluster topology information
+ * needed for ordinal-based capture and restoration of component state. 
Standalone NiFi instances
+ * report ordinal {@code 0} and a count of {@code 1}.
+ */
+public interface ClusterTopologyProvider {
+
+    /**
+     * Returns the ordinal index of the local node among connected cluster 
nodes when sorted
+     * deterministically by API address and API port. Standalone NiFi returns 
{@code 0}.
+     *
+     * @return the ordinal index of the local node, or {@code 0} when not 
clustered
+     */
+    int getLocalNodeOrdinal();
+
+    /**
+     * Returns the number of currently connected cluster nodes. Standalone 
NiFi returns {@code 1}.
+     *
+     * @return the number of connected cluster nodes, or {@code 1} when not 
clustered
+     */
+    int getConnectedNodeCount();
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/registry/flow/mapping/FlowMappingOptions.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/registry/flow/mapping/FlowMappingOptions.java
index d6a96eb32e2..eccf808188d 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/registry/flow/mapping/FlowMappingOptions.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/registry/flow/mapping/FlowMappingOptions.java
@@ -17,6 +17,8 @@
 
 package org.apache.nifi.registry.flow.mapping;
 
+import org.apache.nifi.components.state.StateManagerProvider;
+
 import static java.util.Objects.requireNonNull;
 
 public class FlowMappingOptions {
@@ -29,6 +31,9 @@ public class FlowMappingOptions {
     private final boolean mapControllerServiceReferencesToVersionedId;
     private final boolean mapFlowRegistryClientId;
     private final boolean mapAssetReferences;
+    private final boolean mapComponentState;
+    private final StateManagerProvider stateManagerProvider;
+    private final int localNodeOrdinal;
 
     private FlowMappingOptions(final Builder builder) {
         encryptor = builder.encryptor;
@@ -40,6 +45,9 @@ public class FlowMappingOptions {
         mapControllerServiceReferencesToVersionedId = 
builder.mapControllerServiceReferencesToVersionedId;
         mapFlowRegistryClientId = builder.mapFlowRegistryClientId;
         mapAssetReferences = builder.mapAssetReferences;
+        mapComponentState = builder.mapComponentState;
+        stateManagerProvider = builder.stateManagerProvider;
+        localNodeOrdinal = builder.localNodeOrdinal;
     }
 
     public SensitiveValueEncryptor getSensitiveValueEncryptor() {
@@ -78,6 +86,18 @@ public class FlowMappingOptions {
         return mapAssetReferences;
     }
 
+    public boolean isMapComponentState() {
+        return mapComponentState;
+    }
+
+    public StateManagerProvider getStateManagerProvider() {
+        return stateManagerProvider;
+    }
+
+    public int getLocalNodeOrdinal() {
+        return localNodeOrdinal;
+    }
+
     public static class Builder {
         private SensitiveValueEncryptor encryptor;
         private VersionedComponentStateLookup stateLookup;
@@ -88,6 +108,9 @@ public class FlowMappingOptions {
         private boolean mapControllerServiceReferencesToVersionedId = true;
         private boolean mapFlowRegistryClientId = false;
         private boolean mapAssetReferences = false;
+        private boolean mapComponentState = false;
+        private StateManagerProvider stateManagerProvider;
+        private int localNodeOrdinal = 0;
 
         /**
          * Sets the SensitiveValueEncryptor to use for encrypting sensitive 
values. This value must be set
@@ -190,6 +213,42 @@ public class FlowMappingOptions {
             return this;
         }
 
+        /**
+         * Sets whether or not the component state should be mapped to the 
Versioned Component during export.
+         * If <code>true</code>, the {@link 
#stateManagerProvider(StateManagerProvider)} must be set.
+         *
+         * @param mapComponentState whether or not component state should be 
mapped
+         * @return the builder
+         */
+        public Builder mapComponentState(final boolean mapComponentState) {
+            this.mapComponentState = mapComponentState;
+            return this;
+        }
+
+        /**
+         * Sets the StateManagerProvider to use for retrieving component 
state. This value must be set
+         * if {@link #mapComponentState(boolean) mapComponentState} is set to 
<code>true</code>.
+         *
+         * @param stateManagerProvider the StateManagerProvider to use
+         * @return the builder
+         */
+        public Builder stateManagerProvider(final StateManagerProvider 
stateManagerProvider) {
+            this.stateManagerProvider = stateManagerProvider;
+            return this;
+        }
+
+        /**
+         * Sets the ordinal index of the local node within the cluster. In 
standalone mode, this defaults to 0.
+         * Used during export to key local-scoped state entries.
+         *
+         * @param localNodeOrdinal the ordinal index of the local node
+         * @return the builder
+         */
+        public Builder localNodeOrdinal(final int localNodeOrdinal) {
+            this.localNodeOrdinal = localNodeOrdinal;
+            return this;
+        }
+
         /**
          * Creates a FlowMappingOptions object, or throws an Exception if not 
all required configuration has been provided
          *
@@ -206,6 +265,10 @@ public class FlowMappingOptions {
                 requireNonNull(encryptor, "Property Encryptor must be set when 
sensitive configuration is to be mapped");
             }
 
+            if (mapComponentState) {
+                requireNonNull(stateManagerProvider, "State Manager Provider 
must be set when component state is to be mapped");
+            }
+
             return new FlowMappingOptions(this);
         }
     }
@@ -224,6 +287,7 @@ public class FlowMappingOptions {
         .mapControllerServiceReferencesToVersionedId(true)
         .mapFlowRegistryClientId(false)
         .mapAssetReferences(false)
+        .mapComponentState(false)
         .build();
 
 }
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/FlowController.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/FlowController.java
index 8b92b5e411e..aaa8f90109a 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/FlowController.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/FlowController.java
@@ -249,6 +249,7 @@ import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -274,7 +275,7 @@ import javax.net.ssl.SSLContext;
 
 import static java.util.Objects.requireNonNull;
 
-public class FlowController implements ReportingTaskProvider, 
FlowAnalysisRuleProvider, Authorizable, NodeTypeProvider {
+public class FlowController implements ReportingTaskProvider, 
FlowAnalysisRuleProvider, Authorizable, NodeTypeProvider, 
ClusterTopologyProvider {
     private static final String STANDARD_PYTHON_BRIDGE_IMPLEMENTATION_CLASS = 
"org.apache.nifi.py4j.StandardPythonBridge";
 
     // default repository implementations
@@ -2962,6 +2963,43 @@ public class FlowController implements 
ReportingTaskProvider, FlowAnalysisRulePr
         }
     }
 
+    @Override
+    public int getLocalNodeOrdinal() {
+        final List<NodeIdentifier> sortedConnectedNodes = 
getSortedConnectedNodeIdentifiers();
+        if (sortedConnectedNodes.isEmpty()) {
+            return 0;
+        }
+
+        final NodeIdentifier localNodeId = getNodeId();
+        if (localNodeId == null) {
+            return 0;
+        }
+
+        for (int i = 0; i < sortedConnectedNodes.size(); i++) {
+            if (sortedConnectedNodes.get(i).equals(localNodeId)) {
+                return i;
+            }
+        }
+
+        return 0;
+    }
+
+    @Override
+    public int getConnectedNodeCount() {
+        final List<NodeIdentifier> sortedConnectedNodes = 
getSortedConnectedNodeIdentifiers();
+        return sortedConnectedNodes.isEmpty() ? 1 : 
sortedConnectedNodes.size();
+    }
+
+    private List<NodeIdentifier> getSortedConnectedNodeIdentifiers() {
+        if (!isClustered() || clusterCoordinator == null) {
+            return Collections.emptyList();
+        }
+
+        return 
clusterCoordinator.getNodeIdentifiers(NodeConnectionState.CONNECTED).stream()
+                
.sorted(Comparator.comparing(NodeIdentifier::getApiAddress).thenComparingInt(NodeIdentifier::getApiPort))
+                .toList();
+    }
+
     @Override
     public boolean isConfiguredForClustering() {
         return configuredForClustering;
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/flow/StandardFlowManager.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/flow/StandardFlowManager.java
index 144f3cde8c5..cf74d624310 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/flow/StandardFlowManager.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/flow/StandardFlowManager.java
@@ -306,7 +306,7 @@ public class StandardFlowManager extends 
AbstractFlowManager implements FlowMana
 
         final ProcessGroup group = new 
StandardProcessGroup(requireNonNull(id), 
flowController.getControllerServiceProvider(), processScheduler, 
flowController.getEncryptor(),
             flowController.getExtensionManager(), 
flowController.getStateManagerProvider(), this,
-            flowController.getReloadComponent(), flowController, 
nifiProperties, statelessGroupNodeFactory,
+            flowController.getReloadComponent(), flowController, 
flowController, nifiProperties, statelessGroupNodeFactory,
             flowController.getAssetManager(), connectorId);
 
         onProcessGroupAdded(group);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
index 10f5c9c585c..5a2d96ee2a6 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
@@ -1845,6 +1845,18 @@ public interface NiFiServiceFacade {
      */
     RegisteredFlowSnapshot 
getCurrentFlowSnapshotByGroupIdWithReferencedControllerServices(String 
processGroupId);
 
+    /**
+     * Get the current state of the Process Group with the given ID, converted 
to a Versioned Flow Snapshot for download.
+     * Optionally includes referenced controller services from parent groups 
and component state.
+     *
+     * @param processGroupId the ID of the Process Group
+     * @param includeReferencedServices whether to include referenced 
controller services from parent groups
+     * @param includeComponentState whether to include component state in the 
export. When true, all processors must be stopped
+     *                              and all controller services must be 
disabled.
+     * @return the current Process Group converted to a Versioned Flow 
Snapshot for download
+     */
+    RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupId(String 
processGroupId, boolean includeReferencedServices, boolean 
includeComponentState);
+
     /**
      * Returns the name of the Flow Registry that is registered with the given 
ID. If no Flow Registry exists with the given ID, will return
      * the ID itself as the name
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
index 1b03128abe4..784710c3a34 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
@@ -85,12 +85,14 @@ import 
org.apache.nifi.components.connector.ConnectorUpdateContext;
 import org.apache.nifi.components.connector.Secret;
 import org.apache.nifi.components.connector.secrets.AuthorizableSecret;
 import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.components.state.StateManagerProvider;
 import org.apache.nifi.components.state.StateMap;
 import org.apache.nifi.components.validation.ValidationState;
 import org.apache.nifi.connectable.Connectable;
 import org.apache.nifi.connectable.Connection;
 import org.apache.nifi.connectable.Funnel;
 import org.apache.nifi.connectable.Port;
+import org.apache.nifi.controller.ClusterTopologyProvider;
 import org.apache.nifi.controller.ComponentNode;
 import org.apache.nifi.controller.ControllerService;
 import org.apache.nifi.controller.Counter;
@@ -517,6 +519,8 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
     private FlowRegistryDAO flowRegistryDAO;
     private ParameterContextDAO parameterContextDAO;
     private ClusterCoordinator clusterCoordinator;
+    private ClusterTopologyProvider clusterTopologyProvider;
+    private StateManagerProvider stateManagerProvider;
     private HeartbeatMonitor heartbeatMonitor;
     private LeaderElectionManager leaderElectionManager;
 
@@ -5927,6 +5931,51 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
         return getCurrentFlowSnapshotByGroupId(processGroupId, true);
     }
 
+    @Override
+    public RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupId(final String 
processGroupId, final boolean includeReferencedServices, final boolean 
includeComponentState) {
+        if (!includeComponentState) {
+            return getCurrentFlowSnapshotByGroupId(processGroupId, 
includeReferencedServices);
+        }
+
+        final ProcessGroup processGroup = 
processGroupDAO.getProcessGroup(processGroupId);
+
+        // Validate all processors are stopped and all controller services are 
disabled
+        final List<ProcessorNode> runningProcessors = 
processGroup.findAllProcessors().stream()
+                .filter(p -> p.getPhysicalScheduledState() != 
ScheduledState.STOPPED && p.getPhysicalScheduledState() != 
ScheduledState.DISABLED)
+                .toList();
+        if (!runningProcessors.isEmpty()) {
+            throw new IllegalStateException("Cannot export component state 
because %d processor(s) are not stopped: %s".formatted(
+                    runningProcessors.size(),
+                    runningProcessors.stream().map(p -> "%s 
[%s]".formatted(p.getName(), 
p.getIdentifier())).limit(5).collect(Collectors.joining(", "))));
+        }
+
+        final List<ControllerServiceNode> enabledServices = 
processGroup.findAllControllerServices().stream()
+                .filter(s -> s.getState() != ControllerServiceState.DISABLED)
+                .toList();
+        if (!enabledServices.isEmpty()) {
+            throw new IllegalStateException("Cannot export component state 
because %d controller service(s) are not disabled: %s".formatted(
+                    enabledServices.size(),
+                    enabledServices.stream().map(s -> "%s 
[%s]".formatted(s.getName(), 
s.getIdentifier())).limit(5).collect(Collectors.joining(", "))));
+        }
+
+        final FlowMappingOptions mappingOptions = new 
FlowMappingOptions.Builder()
+                .sensitiveValueEncryptor(null)
+                .stateLookup(VersionedComponentStateLookup.ENABLED_OR_DISABLED)
+                .componentIdLookup(ComponentIdLookup.VERSIONED_OR_GENERATE)
+                .mapPropertyDescriptors(true)
+                .mapSensitiveConfiguration(false)
+                .mapInstanceIdentifiers(false)
+                .mapControllerServiceReferencesToVersionedId(true)
+                .mapFlowRegistryClientId(false)
+                .mapAssetReferences(false)
+                .mapComponentState(includeComponentState)
+                .stateManagerProvider(stateManagerProvider)
+                
.localNodeOrdinal(clusterTopologyProvider.getLocalNodeOrdinal())
+                .build();
+
+        return buildFlowSnapshot(processGroup, processGroupId, 
includeReferencedServices, mappingOptions);
+    }
+
     private Set<String> getAllSubGroups(ProcessGroup processGroup) {
         final Set<String> result = processGroup.findAllProcessGroups().stream()
                 .map(ProcessGroup::getIdentifier)
@@ -5937,22 +5986,27 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
 
     private RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupId(final 
String processGroupId, final boolean includeReferencedControllerServices) {
         final ProcessGroup processGroup = 
processGroupDAO.getProcessGroup(processGroupId);
+        return buildFlowSnapshot(processGroup, processGroupId, 
includeReferencedControllerServices, null);
+    }
 
+    private RegisteredFlowSnapshot buildFlowSnapshot(final ProcessGroup 
processGroup, final String processGroupId,
+                                                      final boolean 
includeReferencedControllerServices, final FlowMappingOptions 
customMappingOptions) {
         // Create a complete (include descendant flows) VersionedProcessGroup 
snapshot of the flow as it is
         // currently without any registry related fields populated, even if 
the flow is currently versioned.
-        final VersionedComponentFlowMapper mapper = 
makeNiFiRegistryFlowMapper(controllerFacade.getExtensionManager());
+        final VersionedComponentFlowMapper mapper = customMappingOptions != 
null
+                ? 
makeNiFiRegistryFlowMapper(controllerFacade.getExtensionManager(), 
customMappingOptions)
+                : 
makeNiFiRegistryFlowMapper(controllerFacade.getExtensionManager());
+
         final InstantiatedVersionedProcessGroup nonVersionedProcessGroup =
                 mapper.mapNonVersionedProcessGroup(processGroup, 
controllerFacade.getControllerServiceProvider());
 
         final Map<String, ParameterProviderReference> 
parameterProviderReferences = new HashMap<>();
-
-        // Create a complete (include descendant flows) map of parameter 
contexts
         final Map<String, VersionedParameterContext> parameterContexts = 
mapper.mapParameterContexts(processGroup, true, parameterProviderReferences);
 
         final Map<String, ExternalControllerServiceReference> 
externalControllerServiceReferences =
                 
Optional.ofNullable(nonVersionedProcessGroup.getExternalControllerServiceReferences()).orElse(Collections.emptyMap());
         final Set<VersionedControllerService> controllerServices = new 
HashSet<>(nonVersionedProcessGroup.getControllerServices());
-        final RegisteredFlowSnapshot nonVersionedFlowSnapshot = new 
RegisteredFlowSnapshot();
+        final RegisteredFlowSnapshot flowSnapshot = new 
RegisteredFlowSnapshot();
 
         ProcessGroup parentGroup = processGroup.getParent();
 
@@ -5968,24 +6022,25 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
 
                     if 
(externalControllerServiceReferences.keySet().contains(versionedControllerService.getIdentifier()))
 {
                         
versionedControllerService.setGroupIdentifier(processGroupId);
+                        versionedControllerService.setComponentState(null);
                         externalServices.add(versionedControllerService);
                     }
                 }
             } while ((parentGroup = parentGroup.getParent()) != null);
 
             controllerServices.addAll(externalServices);
-            nonVersionedFlowSnapshot.setExternalControllerServices(new 
HashMap<>());
+            flowSnapshot.setExternalControllerServices(new HashMap<>());
         } else {
-            
nonVersionedFlowSnapshot.setExternalControllerServices(externalControllerServiceReferences);
+            
flowSnapshot.setExternalControllerServices(externalControllerServiceReferences);
         }
 
         nonVersionedProcessGroup.setControllerServices(controllerServices);
-        nonVersionedFlowSnapshot.setFlowContents(nonVersionedProcessGroup);
-        
nonVersionedFlowSnapshot.setParameterProviders(parameterProviderReferences);
-        nonVersionedFlowSnapshot.setParameterContexts(parameterContexts);
-        
nonVersionedFlowSnapshot.setFlowEncodingVersion(FlowRegistryUtil.FLOW_ENCODING_VERSION);
+        flowSnapshot.setFlowContents(nonVersionedProcessGroup);
+        flowSnapshot.setParameterProviders(parameterProviderReferences);
+        flowSnapshot.setParameterContexts(parameterContexts);
+        
flowSnapshot.setFlowEncodingVersion(FlowRegistryUtil.FLOW_ENCODING_VERSION);
 
-        return nonVersionedFlowSnapshot;
+        return flowSnapshot;
     }
 
     @Override
@@ -7929,6 +7984,16 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
         this.clusterCoordinator = coordinator;
     }
 
+    @Autowired
+    public void setClusterTopologyProvider(final ClusterTopologyProvider 
clusterTopologyProvider) {
+        this.clusterTopologyProvider = clusterTopologyProvider;
+    }
+
+    @Autowired
+    public void setStateManagerProvider(final StateManagerProvider 
stateManagerProvider) {
+        this.stateManagerProvider = stateManagerProvider;
+    }
+
     @Autowired(required = false)
     public void setHeartbeatMonitor(final HeartbeatMonitor heartbeatMonitor) {
         this.heartbeatMonitor = heartbeatMonitor;
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ProcessGroupResource.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ProcessGroupResource.java
index 05807925eef..c42ff3273a0 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ProcessGroupResource.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ProcessGroupResource.java
@@ -62,9 +62,11 @@ import org.apache.nifi.connectable.ConnectableType;
 import org.apache.nifi.flow.ConnectableComponent;
 import org.apache.nifi.flow.ExecutionEngine;
 import org.apache.nifi.flow.VersionedComponent;
+import org.apache.nifi.flow.VersionedControllerService;
 import org.apache.nifi.flow.VersionedFlowCoordinates;
 import org.apache.nifi.flow.VersionedParameterContext;
 import org.apache.nifi.flow.VersionedProcessGroup;
+import org.apache.nifi.flow.VersionedProcessor;
 import org.apache.nifi.flow.VersionedPropertyDescriptor;
 import org.apache.nifi.groups.VersionedComponentAdditions;
 import org.apache.nifi.parameter.ParameterContext;
@@ -290,7 +292,8 @@ public class ProcessGroupResource extends 
FlowUpdateResource<ProcessGroupImportE
                     @ApiResponse(responseCode = "409", description = "The 
request was valid but NiFi was not in the appropriate state to process it.")
             },
             security = {
-                    @SecurityRequirement(name = "Read - 
/process-groups/{uuid}")
+                    @SecurityRequirement(name = "Read - 
/process-groups/{uuid}"),
+                    @SecurityRequirement(name = "Write - 
/process-groups/{uuid} - Only required when includeComponentState is true")
             }
     )
     public Response exportProcessGroup(
@@ -301,19 +304,35 @@ public class ProcessGroupResource extends 
FlowUpdateResource<ProcessGroupImportE
             @PathParam("id") final String groupId,
             @Parameter(description = "If referenced services from outside the 
target group should be included")
             @QueryParam("includeReferencedServices")
-            @DefaultValue("false") boolean includeReferencedServices) {
-        // authorize access
+            @DefaultValue("false") final boolean includeReferencedServices,
+            @Parameter(description = "If component state should be included in 
the exported flow definition. "
+                    + "Requires all processors to be stopped and all 
controller services to be disabled.")
+            @QueryParam("includeComponentState")
+            @DefaultValue("false") final boolean includeComponentState) {
+
+        // When exporting with component state in a cluster, replicate to all 
nodes so that each contributes its
+        // LOCAL state. The framework's ExportProcessGroupEndpointMerger 
combines the per-node localNodeStates.
+        if (includeComponentState && isReplicateRequest()) {
+            return replicate(HttpMethod.GET);
+        }
+
+        // authorize access — exporting with component state requires WRITE 
(state access requires write permission)
+        final RequestAction requiredAction = includeComponentState ? 
RequestAction.WRITE : RequestAction.READ;
         serviceFacade.authorizeAccess(lookup -> {
-            // ensure access to process groups (nested), encapsulated 
controller services and referenced parameter contexts
             final ProcessGroupAuthorizable groupAuthorizable = 
lookup.getProcessGroup(groupId);
-            authorizeProcessGroup(groupAuthorizable, authorizer, lookup, 
RequestAction.READ, true,
+            authorizeProcessGroup(groupAuthorizable, authorizer, lookup, 
requiredAction, true,
                     false, false, false, true);
         });
 
         // get the versioned flow
-        final RegisteredFlowSnapshot currentVersionedFlowSnapshot = 
includeReferencedServices
-                ? 
serviceFacade.getCurrentFlowSnapshotByGroupIdWithReferencedControllerServices(groupId)
-                : serviceFacade.getCurrentFlowSnapshotByGroupId(groupId);
+        final RegisteredFlowSnapshot currentVersionedFlowSnapshot;
+        if (includeComponentState) {
+            currentVersionedFlowSnapshot = 
serviceFacade.getCurrentFlowSnapshotByGroupId(groupId, 
includeReferencedServices, true);
+        } else if (includeReferencedServices) {
+            currentVersionedFlowSnapshot = 
serviceFacade.getCurrentFlowSnapshotByGroupIdWithReferencedControllerServices(groupId);
+        } else {
+            currentVersionedFlowSnapshot = 
serviceFacade.getCurrentFlowSnapshotByGroupId(groupId);
+        }
 
         // determine the name of the attachment - possible issues with spaces 
in file names
         final VersionedProcessGroup currentVersionedProcessGroup = 
currentVersionedFlowSnapshot.getFlowContents();
@@ -2582,6 +2601,11 @@ public class ProcessGroupResource extends 
FlowUpdateResource<ProcessGroupImportE
             throw new IllegalArgumentException("Versioned Flow Snapshot must 
be supplied");
         }
 
+        if (containsComponentState(versionedFlowSnapshot.getFlowContents())) {
+            throw new IllegalArgumentException("Cannot replace an existing 
Process Group with a flow definition that contains component state. "
+                    + "Component state can only be restored when uploading a 
flow definition as a new Process Group.");
+        }
+
         // remove any registry-specific versioning content which could be 
present if the flow was exported from registry
         versionedFlowSnapshot.setFlow(null);
         versionedFlowSnapshot.setBucket(null);
@@ -2606,6 +2630,31 @@ public class ProcessGroupResource extends 
FlowUpdateResource<ProcessGroupImportE
         }
     }
 
+    private boolean containsComponentState(final VersionedProcessGroup group) {
+        if (group.getProcessors() != null) {
+            for (final VersionedProcessor processor : group.getProcessors()) {
+                if (processor.getComponentState() != null) {
+                    return true;
+                }
+            }
+        }
+        if (group.getControllerServices() != null) {
+            for (final VersionedControllerService service : 
group.getControllerServices()) {
+                if (service.getComponentState() != null) {
+                    return true;
+                }
+            }
+        }
+        if (group.getProcessGroups() != null) {
+            for (final VersionedProcessGroup child : group.getProcessGroups()) 
{
+                if (containsComponentState(child)) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
     /**
      * Uploads the specified versioned flow definition and adds it to a new 
process group.
      *
@@ -3245,6 +3294,11 @@ public class ProcessGroupResource extends 
FlowUpdateResource<ProcessGroupImportE
             throw new IllegalArgumentException("Versioned Flow Snapshot must 
be supplied.");
         }
 
+        if (containsComponentState(requestFlowSnapshot.getFlowContents())) {
+            throw new IllegalArgumentException("Cannot replace an existing 
Process Group with a flow definition that contains component state. "
+                    + "Component state can only be restored when uploading a 
flow definition as a new Process Group.");
+        }
+
         // Perform the request
         if (isReplicateRequest()) {
             return replicate(HttpMethod.PUT, importEntity);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestProcessGroupResource.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestProcessGroupResource.java
index f34f0704afe..7fb43da69f8 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestProcessGroupResource.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestProcessGroupResource.java
@@ -60,7 +60,7 @@ public class TestProcessGroupResource {
         
when(versionedFlowSnapshot.getFlowContents()).thenReturn(versionedProcessGroup);
         when(versionedProcessGroup.getName()).thenReturn("flowname");
 
-        try (Response response = 
processGroupResource.exportProcessGroup(groupId, false)) {
+        try (Response response = 
processGroupResource.exportProcessGroup(groupId, false, false)) {
             assertEquals(200, response.getStatus());
             assertEquals(versionedFlowSnapshot, response.getEntity());
         }
diff --git 
a/nifi-stateless/nifi-stateless-bundle/nifi-stateless-engine/src/main/java/org/apache/nifi/stateless/engine/StatelessFlowManager.java
 
b/nifi-stateless/nifi-stateless-bundle/nifi-stateless-engine/src/main/java/org/apache/nifi/stateless/engine/StatelessFlowManager.java
index a637745bb61..cc5c78688e0 100644
--- 
a/nifi-stateless/nifi-stateless-bundle/nifi-stateless-engine/src/main/java/org/apache/nifi/stateless/engine/StatelessFlowManager.java
+++ 
b/nifi-stateless/nifi-stateless-bundle/nifi-stateless-engine/src/main/java/org/apache/nifi/stateless/engine/StatelessFlowManager.java
@@ -30,6 +30,7 @@ import org.apache.nifi.connectable.Funnel;
 import org.apache.nifi.connectable.LocalPort;
 import org.apache.nifi.connectable.Port;
 import org.apache.nifi.connectable.StandardConnection;
+import org.apache.nifi.controller.ClusterTopologyProvider;
 import org.apache.nifi.controller.ConfigurationContext;
 import org.apache.nifi.controller.ControllerService;
 import org.apache.nifi.controller.FlowAnalysisRuleNode;
@@ -93,6 +94,18 @@ import static java.util.Objects.requireNonNull;
 public class StatelessFlowManager extends AbstractFlowManager implements 
FlowManager {
     private static final Logger logger = 
LoggerFactory.getLogger(StatelessFlowManager.class);
 
+    private static final ClusterTopologyProvider 
STANDALONE_CLUSTER_TOPOLOGY_PROVIDER = new ClusterTopologyProvider() {
+        @Override
+        public int getLocalNodeOrdinal() {
+            return 0;
+        }
+
+        @Override
+        public int getConnectedNodeCount() {
+            return 1;
+        }
+    };
+
     private final StatelessEngine statelessEngine;
     private final SSLContext sslContext;
     private final BulletinRepository bulletinRepository;
@@ -235,6 +248,7 @@ public class StatelessFlowManager extends 
AbstractFlowManager implements FlowMan
             this,
             statelessEngine.getReloadComponent(),
             new StatelessNodeTypeProvider(),
+            STANDALONE_CLUSTER_TOPOLOGY_PROVIDER,
             null,
             group -> null,
             statelessEngine.getAssetManager(),
diff --git 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/StatefulCountProcessor.java
 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/StatefulCountProcessor.java
new file mode 100644
index 00000000000..85d13228e0c
--- /dev/null
+++ 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/StatefulCountProcessor.java
@@ -0,0 +1,70 @@
+/*
+ * 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.Stateful;
+import org.apache.nifi.annotation.configuration.DefaultSchedule;
+import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.components.state.StateMap;
+import org.apache.nifi.flowfile.FlowFile;
+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.io.IOException;
+import java.util.Map;
+import java.util.Set;
+
+@DefaultSchedule(period = "100 ms")
+@Stateful(scopes = {Scope.CLUSTER, Scope.LOCAL}, description = "Stores a 
counter in both cluster and local state")
+public class StatefulCountProcessor extends AbstractProcessor {
+
+    public static final Relationship REL_SUCCESS = new Relationship.Builder()
+            .name("success")
+            .build();
+
+    @Override
+    public Set<Relationship> getRelationships() {
+        return Set.of(REL_SUCCESS);
+    }
+
+    @Override
+    public void onTrigger(final ProcessContext context, final ProcessSession 
session) throws ProcessException {
+        FlowFile flowFile = session.get();
+        if (flowFile == null) {
+            flowFile = session.create();
+        }
+
+        try {
+            incrementState(session, Scope.CLUSTER);
+            incrementState(session, Scope.LOCAL);
+        } catch (final IOException e) {
+            throw new ProcessException(e);
+        }
+
+        session.transfer(flowFile, REL_SUCCESS);
+    }
+
+    private void incrementState(final ProcessSession session, final Scope 
scope) throws IOException {
+        final StateMap stateMap = session.getState(scope);
+        final String countValue = stateMap.toMap().get("count");
+        final int count = countValue == null ? 0 : 
Integer.parseInt(countValue);
+        session.setState(Map.of("count", String.valueOf(count + 1)), scope);
+    }
+}
diff --git 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
index 2895fadb5c0..80655da6f36 100644
--- 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
+++ 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
@@ -50,6 +50,7 @@ org.apache.nifi.processors.tests.system.SetState
 org.apache.nifi.processors.tests.system.Sleep
 org.apache.nifi.processors.tests.system.SplitByLine
 org.apache.nifi.processors.tests.system.SplitTextByLine
+org.apache.nifi.processors.tests.system.StatefulCountProcessor
 org.apache.nifi.processors.tests.system.TerminateFlowFile
 org.apache.nifi.processors.tests.system.TransferBatch
 org.apache.nifi.processors.tests.system.ThrowExceptionInFlowFileFilter
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/pg/ClusterFlowDefinitionExportImportStateIT.java
 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/pg/ClusterFlowDefinitionExportImportStateIT.java
new file mode 100644
index 00000000000..8eb586b77ff
--- /dev/null
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/pg/ClusterFlowDefinitionExportImportStateIT.java
@@ -0,0 +1,371 @@
+/*
+ * 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.pg;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.flow.VersionedComponentState;
+import org.apache.nifi.flow.VersionedProcessGroup;
+import org.apache.nifi.flow.VersionedProcessor;
+import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
+import org.apache.nifi.tests.system.NiFiInstanceFactory;
+import org.apache.nifi.tests.system.NiFiSystemIT;
+import org.apache.nifi.toolkit.client.NiFiClientException;
+import org.apache.nifi.web.api.dto.ComponentStateDTO;
+import org.apache.nifi.web.api.dto.StateEntryDTO;
+import org.apache.nifi.web.api.entity.ComponentStateEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupImportEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupReplaceRequestEntity;
+import org.apache.nifi.web.api.entity.ProcessorEntity;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ClusterFlowDefinitionExportImportStateIT extends NiFiSystemIT {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, 
false);
+
+    @Override
+    public NiFiInstanceFactory getInstanceFactory() {
+        return createTwoNodeInstanceFactory();
+    }
+
+    @Test
+    public void testClusterExportCapturesClusterState() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("GenerateFlowFile", pg.getId());
+        getClientUtil().updateProcessorProperties(stateful, 
Collections.singletonMap("State Scope", "CLUSTER"));
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.CLUSTER);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final File exportFile = new File("target/st13-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, true, exportFile);
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        final VersionedProcessor proc = 
findProcessorByType(snapshot.getFlowContents(), "GenerateFlowFile");
+        assertNotNull(proc);
+        assertNotNull(proc.getComponentState());
+        assertNotNull(proc.getComponentState().getClusterState());
+        assertNotNull(proc.getComponentState().getClusterState().get("count"));
+    }
+
+    @Test
+    public void testClusterExportCapturesLocalStateFromBothNodes() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("GenerateFlowFile", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForLocalStatePopulatedOnAllNodes(stateful.getId(), 2);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final File exportFile = new File("target/st14-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, true, exportFile);
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        final VersionedProcessor proc = 
findProcessorByType(snapshot.getFlowContents(), "GenerateFlowFile");
+        assertNotNull(proc);
+        assertNotNull(proc.getComponentState());
+        assertNotNull(proc.getComponentState().getLocalNodeStates());
+        assertEquals(2, proc.getComponentState().getLocalNodeStates().size(),
+                "Should have local state from both nodes");
+        assertNotNull(proc.getComponentState().getLocalNodeStates().get(0));
+        assertNotNull(proc.getComponentState().getLocalNodeStates().get(1));
+    }
+
+    @Test
+    public void testClusterExportCapturesBothScopes() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.CLUSTER);
+        waitForLocalStatePopulatedOnAllNodes(stateful.getId(), 2);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final File exportFile = new File("target/st15-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, true, exportFile);
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        final VersionedProcessor proc = 
findProcessorByType(snapshot.getFlowContents(), "StatefulCountProcessor");
+        assertNotNull(proc);
+
+        final VersionedComponentState state = proc.getComponentState();
+        assertNotNull(state);
+        assertNotNull(state.getClusterState(), "Cluster state should be 
present");
+        assertNotNull(state.getClusterState().get("count"), "Cluster state 
should contain count");
+        assertNotNull(state.getLocalNodeStates(), "Local node states should be 
present");
+        assertEquals(2, state.getLocalNodeStates().size());
+        
assertNotNull(state.getLocalNodeStates().get(0).getState().get("count"));
+        
assertNotNull(state.getLocalNodeStates().get(1).getState().get("count"));
+    }
+
+    @Test
+    public void testClusterRoundTripSameTopologyBothScopes() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.CLUSTER);
+        waitForLocalStatePopulatedOnAllNodes(stateful.getId(), 2);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final Map<String, String> originalClusterState = 
getProcessorState(stateful.getId(), Scope.CLUSTER);
+        final Map<String, Map<String, String>> originalLocalStatesByNode = 
getProcessorLocalStatesByNode(stateful.getId());
+        assertEquals(2, originalLocalStatesByNode.size(),
+                "Local state should be populated on both cluster nodes prior 
to export");
+
+        final File exportFile = new File("target/st16-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, true, exportFile);
+
+        emptyQueuesAndDeleteProcessGroup(pg);
+
+        final ProcessGroupEntity uploaded = 
getNifiClient().getProcessGroupClient().upload("root", exportFile, 
"ImportedGroup", 0.0, 0.0);
+        final ProcessorEntity importedProcessor = 
findProcessorByTypeInGroup(uploaded.getId(), "StatefulCountProcessor");
+        assertNotNull(importedProcessor);
+
+        waitForLocalStatePopulatedOnAllNodes(importedProcessor.getId(), 2);
+
+        final Map<String, String> importedClusterState = 
getProcessorState(importedProcessor.getId(), Scope.CLUSTER);
+        assertEquals(originalClusterState.get("count"), 
importedClusterState.get("count"),
+                "Cluster state should be restored after round-trip");
+
+        final Map<String, Map<String, String>> importedLocalStatesByNode = 
getProcessorLocalStatesByNode(importedProcessor.getId());
+        assertEquals(originalLocalStatesByNode, importedLocalStatesByNode,
+                "Local state should be restored to the same node it was 
exported from after round-trip");
+    }
+
+    @Test
+    public void testClusterExportRunningProcessorReturns409() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.LOCAL);
+
+        try {
+            final File exportFile = new File("target/st20-export.json");
+            
getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), false, 
true, exportFile);
+            throw new AssertionError("Expected export to fail when processors 
are running");
+        } catch (final NiFiClientException e) {
+            assertNotNull(e.getMessage());
+        } finally {
+            getClientUtil().stopProcessor(stateful);
+            getClientUtil().waitForStoppedProcessor(stateful.getId());
+        }
+    }
+
+    @Test
+    public void testClusterReplaceRejectsFlowDefinitionWithComponentState() 
throws NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.CLUSTER);
+        waitForStatePopulated(stateful.getId(), Scope.LOCAL);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final File exportFile = new File("target/st21-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, true, exportFile);
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        final NiFiClientException exception = 
assertThrows(NiFiClientException.class, () -> replaceProcessGroup(pg, 
snapshot));
+        assertTrue(exception.getMessage().contains("component state"),
+                "Expected rejection message about component state but got: " + 
exception.getMessage());
+    }
+
+    @Test
+    public void testClusterImportWithoutComponentStateHasNoState() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.CLUSTER);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final File exportFile = new File("target/st22-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, false, exportFile);
+
+        emptyQueuesAndDeleteProcessGroup(pg);
+
+        final ProcessGroupEntity uploaded = 
getNifiClient().getProcessGroupClient().upload("root", exportFile, 
"ImportedGroup", 0.0, 0.0);
+        final ProcessorEntity importedProcessor = 
findProcessorByTypeInGroup(uploaded.getId(), "StatefulCountProcessor");
+        assertNotNull(importedProcessor);
+
+        final Map<String, String> importedClusterState = 
getProcessorState(importedProcessor.getId(), Scope.CLUSTER);
+        assertTrue(importedClusterState.isEmpty(), "State should be empty when 
imported without componentState");
+    }
+
+    private void waitForStatePopulated(final String processorId, final Scope 
scope) throws InterruptedException {
+        waitFor(() -> {
+            try {
+                final Map<String, String> state = 
getProcessorState(processorId, scope);
+                return state.get("count") != null;
+            } catch (final Exception e) {
+                return false;
+            }
+        });
+    }
+
+    private void waitForLocalStatePopulatedOnAllNodes(final String 
processorId, final int expectedNodeCount) throws InterruptedException {
+        waitFor(() -> {
+            try {
+                final Map<String, Map<String, String>> byNode = 
getProcessorLocalStatesByNode(processorId);
+                if (byNode.size() != expectedNodeCount) {
+                    return false;
+                }
+                for (final Map<String, String> nodeState : byNode.values()) {
+                    if (nodeState.get("count") == null) {
+                        return false;
+                    }
+                }
+                return true;
+            } catch (final Exception e) {
+                return false;
+            }
+        });
+    }
+
+    private Map<String, Map<String, String>> 
getProcessorLocalStatesByNode(final String processorId) throws 
NiFiClientException, IOException {
+        final ComponentStateEntity stateEntity = 
getNifiClient().getProcessorClient().getProcessorState(processorId);
+        final ComponentStateDTO componentState = 
stateEntity.getComponentState();
+        final Map<String, Map<String, String>> byNode = new HashMap<>();
+        if (componentState != null && componentState.getLocalState() != null 
&& componentState.getLocalState().getState() != null) {
+            for (final StateEntryDTO entry : 
componentState.getLocalState().getState()) {
+                final String nodeId = entry.getClusterNodeId();
+                byNode.computeIfAbsent(nodeId, id -> new 
HashMap<>()).put(entry.getKey(), entry.getValue());
+            }
+        }
+        return byNode;
+    }
+
+    private Map<String, String> getProcessorState(final String processorId, 
final Scope scope) throws NiFiClientException, IOException {
+        final ComponentStateEntity stateEntity = 
getNifiClient().getProcessorClient().getProcessorState(processorId);
+        final ComponentStateDTO componentState = 
stateEntity.getComponentState();
+        final Map<String, String> result = new HashMap<>();
+
+        switch (scope) {
+            case LOCAL:
+                if (componentState != null && componentState.getLocalState() 
!= null && componentState.getLocalState().getState() != null) {
+                    for (final StateEntryDTO entry : 
componentState.getLocalState().getState()) {
+                        result.put(entry.getKey(), entry.getValue());
+                    }
+                }
+                break;
+            case CLUSTER:
+                if (componentState != null && componentState.getClusterState() 
!= null && componentState.getClusterState().getState() != null) {
+                    for (final StateEntryDTO entry : 
componentState.getClusterState().getState()) {
+                        result.put(entry.getKey(), entry.getValue());
+                    }
+                }
+                break;
+        }
+        return result;
+    }
+
+    private VersionedProcessor findProcessorByType(final VersionedProcessGroup 
group, final String typeSuffix) {
+        if (group.getProcessors() != null) {
+            for (final VersionedProcessor vp : group.getProcessors()) {
+                if (vp.getType() != null && vp.getType().endsWith(typeSuffix)) 
{
+                    return vp;
+                }
+            }
+        }
+        return null;
+    }
+
+    private ProcessorEntity findProcessorByTypeInGroup(final String groupId, 
final String typeSuffix) throws NiFiClientException, IOException {
+        return getNifiClient().getFlowClient().getProcessGroup(groupId)
+                .getProcessGroupFlow().getFlow().getProcessors().stream()
+                .filter(pe -> pe.getComponent().getType().endsWith(typeSuffix))
+                .findFirst().orElse(null);
+    }
+
+    private void emptyQueuesAndDeleteProcessGroup(final ProcessGroupEntity pg) 
throws NiFiClientException, IOException, InterruptedException {
+        getNifiClient().getProcessGroupClient().emptyQueues(pg.getId());
+        waitFor(() -> {
+            try {
+                return 
getNifiClient().getProcessGroupClient().getProcessGroup(pg.getId())
+                        
.getStatus().getAggregateSnapshot().getQueuedCount().equals("0");
+            } catch (final Exception e) {
+                return false;
+            }
+        });
+        final ProcessGroupEntity refreshed = 
getNifiClient().getProcessGroupClient().getProcessGroup(pg.getId());
+        refreshed.setDisconnectedNodeAcknowledged(true);
+        getNifiClient().getProcessGroupClient().deleteProcessGroup(refreshed);
+    }
+
+    private void replaceProcessGroup(final ProcessGroupEntity pg, final 
RegisteredFlowSnapshot snapshot) throws NiFiClientException, IOException, 
InterruptedException {
+        final ProcessGroupImportEntity importEntity = new 
ProcessGroupImportEntity();
+        importEntity.setVersionedFlowSnapshot(snapshot);
+        
importEntity.setProcessGroupRevision(getNifiClient().getProcessGroupClient().getProcessGroup(pg.getId()).getRevision());
+
+        final ProcessGroupReplaceRequestEntity replaceRequest =
+                
getNifiClient().getProcessGroupClient().replaceProcessGroup(pg.getId(), 
importEntity);
+        final String requestId = replaceRequest.getRequest().getRequestId();
+
+        waitFor(() -> {
+            try {
+                final ProcessGroupReplaceRequestEntity req =
+                        
getNifiClient().getProcessGroupClient().getProcessGroupReplaceRequest(pg.getId(),
 requestId);
+                return req != null && req.getRequest().isComplete();
+            } catch (final Exception e) {
+                return false;
+            }
+        });
+
+        final ProcessGroupReplaceRequestEntity finalRequest =
+                
getNifiClient().getProcessGroupClient().getProcessGroupReplaceRequest(pg.getId(),
 requestId);
+        assertNull(finalRequest.getRequest().getFailureReason(),
+                "Replace failed: 
%s".formatted(finalRequest.getRequest().getFailureReason()));
+    }
+}
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/pg/FlowDefinitionExportImportStateIT.java
 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/pg/FlowDefinitionExportImportStateIT.java
new file mode 100644
index 00000000000..8335788f866
--- /dev/null
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/pg/FlowDefinitionExportImportStateIT.java
@@ -0,0 +1,484 @@
+/*
+ * 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.pg;
+
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.flow.VersionedComponentState;
+import org.apache.nifi.flow.VersionedNodeState;
+import org.apache.nifi.flow.VersionedProcessGroup;
+import org.apache.nifi.flow.VersionedProcessor;
+import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
+import org.apache.nifi.tests.system.NiFiSystemIT;
+import org.apache.nifi.toolkit.client.NiFiClientException;
+import org.apache.nifi.web.api.dto.ComponentStateDTO;
+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.ProcessGroupEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupImportEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupReplaceRequestEntity;
+import org.apache.nifi.web.api.entity.ProcessorEntity;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class FlowDefinitionExportImportStateIT extends NiFiSystemIT {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, 
false);
+
+    @Test
+    public void testExportWithoutStateFlagHasNoComponentState() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.LOCAL);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final File exportFile = new File("target/st1-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
false, false, exportFile);
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        for (final VersionedProcessor vp : 
snapshot.getFlowContents().getProcessors()) {
+            assertNull(vp.getComponentState(), "componentState should be null 
when includeComponentState=false");
+        }
+    }
+
+    @Test
+    public void testExportWithStateFlagHasBothScopes() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.LOCAL);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final File exportFile = new File("target/st2-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, true, exportFile);
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        final VersionedProcessor proc = 
findProcessorByType(snapshot.getFlowContents(), "StatefulCountProcessor");
+        assertNotNull(proc);
+
+        final VersionedComponentState state = proc.getComponentState();
+        assertNotNull(state, "componentState should be present when 
includeComponentState=true");
+        assertNotNull(state.getLocalNodeStates(), "localNodeStates should be 
present for StatefulCountProcessor");
+        assertEquals(1, state.getLocalNodeStates().size());
+        final VersionedNodeState nodeState = state.getLocalNodeStates().get(0);
+        assertNotNull(nodeState);
+        assertNotNull(nodeState.getState().get("count"));
+        assertNull(state.getClusterState(), "clusterState should not be 
exported on a standalone node since the cluster state provider is not enabled");
+    }
+
+    @Test
+    public void testExportWithStateWhileRunningReturns409() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.LOCAL);
+
+        try {
+            final File exportFile = new File("target/st3-export.json");
+            
getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), false, 
true, exportFile);
+            throw new AssertionError("Expected export to fail when processors 
are running");
+        } catch (final NiFiClientException e) {
+            assertNotNull(e.getMessage());
+        } finally {
+            getClientUtil().stopProcessor(stateful);
+            getClientUtil().waitForStoppedProcessor(stateful.getId());
+        }
+    }
+
+    @Test
+    public void testExportWithStateWhileControllerServiceEnabledReturns409() 
throws NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ControllerServiceEntity service = 
getClientUtil().createControllerService("StandardCountService", pg.getId());
+        getClientUtil().enableControllerService(service);
+
+        try {
+            final File exportFile = new File("target/st4-export.json");
+            
getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), false, 
true, exportFile);
+            throw new AssertionError("Expected export to fail when controller 
services are enabled");
+        } catch (final NiFiClientException e) {
+            assertNotNull(e.getMessage());
+        } finally {
+            getClientUtil().disableControllerService(service);
+        }
+    }
+
+    @Test
+    public void 
testExportWithStateFlagNonStatefulProcessorHasNoComponentState() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.LOCAL);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final File exportFile = new File("target/st5-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, true, exportFile);
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        final VersionedProcessor statefulVersioned = 
findProcessorByType(snapshot.getFlowContents(), "StatefulCountProcessor");
+        final VersionedProcessor terminateVersioned = 
findProcessorByType(snapshot.getFlowContents(), "TerminateFlowFile");
+
+        assertNotNull(statefulVersioned.getComponentState(), "Stateful 
processor should have componentState");
+        assertNull(terminateVersioned.getComponentState(), "Non-stateful 
processor should not have componentState");
+    }
+
+    @Test
+    public void testExportDefaultNoParamHasNoState() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.LOCAL);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final File exportFile = new File("target/st6-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
false, exportFile);
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        for (final VersionedProcessor vp : 
snapshot.getFlowContents().getProcessors()) {
+            assertNull(vp.getComponentState(), "componentState should be null 
by default");
+        }
+    }
+
+    @Test
+    public void testStandaloneRoundTripViaUploadLocalState() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.LOCAL);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final Map<String, String> originalLocalState = 
getProcessorState(stateful.getId(), Scope.LOCAL);
+        assertNotNull(originalLocalState.get("count"));
+
+        final File exportFile = new File("target/st7-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, true, exportFile);
+
+        emptyQueuesAndDeleteProcessGroup(pg);
+
+        final ProcessGroupEntity uploaded = 
getNifiClient().getProcessGroupClient().upload("root", exportFile, 
"ImportedGroup", 0.0, 0.0);
+
+        final ProcessorEntity importedProcessor = 
findProcessorByTypeInGroup(uploaded.getId(), "StatefulCountProcessor");
+        assertNotNull(importedProcessor);
+
+        final Map<String, String> importedLocalState = 
getProcessorState(importedProcessor.getId(), Scope.LOCAL);
+        assertEquals(originalLocalState.get("count"), 
importedLocalState.get("count"), "Local state count should match after 
round-trip");
+    }
+
+    @Test
+    public void testReplaceRejectsFlowDefinitionWithComponentState() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.LOCAL);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final File exportFile = new File("target/st8-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, true, exportFile);
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        final NiFiClientException exception = 
assertThrows(NiFiClientException.class, () -> replaceProcessGroup(pg, 
snapshot));
+        assertTrue(exception.getMessage().contains("component state"),
+                "Expected rejection message about component state but got: " + 
exception.getMessage());
+    }
+
+    @Test
+    public void testImportFlowWithoutComponentStateHasNoState() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.LOCAL);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final File exportFile = new File("target/st9-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, false, exportFile);
+
+        emptyQueuesAndDeleteProcessGroup(pg);
+
+        final ProcessGroupEntity uploaded = 
getNifiClient().getProcessGroupClient().upload("root", exportFile, 
"ImportedGroup", 0.0, 0.0);
+        final ProcessorEntity importedProcessor = 
findProcessorByTypeInGroup(uploaded.getId(), "StatefulCountProcessor");
+        assertNotNull(importedProcessor);
+
+        final Map<String, String> importedLocalState = 
getProcessorState(importedProcessor.getId(), Scope.LOCAL);
+        assertTrue(importedLocalState.isEmpty(), "State should be empty when 
imported without componentState");
+    }
+
+    @Test
+    public void testNestedProcessGroupsStateExportedRecursively() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity parent = 
getClientUtil().createProcessGroup("ParentGroup", "root");
+        final ProcessGroupEntity child = 
getClientUtil().createProcessGroup("ChildGroup", parent.getId());
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", child.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", child.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.LOCAL);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final Map<String, String> originalLocalState = 
getProcessorState(stateful.getId(), Scope.LOCAL);
+
+        final File exportFile = new File("target/st10-export.json");
+        
getNifiClient().getProcessGroupClient().exportProcessGroup(parent.getId(), 
true, true, exportFile);
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        final VersionedProcessGroup childGroup = 
snapshot.getFlowContents().getProcessGroups().iterator().next();
+        final VersionedProcessor nestedProcessor = 
findProcessorByType(childGroup, "StatefulCountProcessor");
+        assertNotNull(nestedProcessor);
+        assertNotNull(nestedProcessor.getComponentState(), "Nested processor 
should have componentState");
+        
assertNotNull(nestedProcessor.getComponentState().getLocalNodeStates(), "Nested 
processor should have localNodeStates");
+
+        emptyQueuesAndDeleteProcessGroup(parent);
+
+        final ProcessGroupEntity uploaded = 
getNifiClient().getProcessGroupClient().upload("root", exportFile, 
"ImportedParent", 0.0, 0.0);
+        final ProcessGroupEntity importedChild = 
getNifiClient().getFlowClient().getProcessGroup(uploaded.getId())
+                
.getProcessGroupFlow().getFlow().getProcessGroups().iterator().next();
+        final ProcessorEntity importedProcessor = 
findProcessorByTypeInGroup(importedChild.getId(), "StatefulCountProcessor");
+        assertNotNull(importedProcessor);
+
+        assertEquals(originalLocalState.get("count"), 
getProcessorState(importedProcessor.getId(), Scope.LOCAL).get("count"));
+    }
+
+    @Test
+    public void testMultipleStatefulProcessorsEachGetsOwnState() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity gen1 = 
getClientUtil().createProcessor("GenerateFlowFile", pg.getId());
+        final ProcessorEntity gen2 = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(gen1, terminate, "success");
+        getClientUtil().createConnection(gen2, terminate, "success");
+
+        getClientUtil().startProcessor(gen1);
+        getClientUtil().startProcessor(gen2);
+        waitForStatePopulated(gen1.getId(), Scope.LOCAL);
+        waitForStatePopulated(gen2.getId(), Scope.LOCAL);
+        getClientUtil().stopProcessor(gen1);
+        getClientUtil().stopProcessor(gen2);
+        getClientUtil().waitForStoppedProcessor(gen1.getId());
+        getClientUtil().waitForStoppedProcessor(gen2.getId());
+
+        final Map<String, String> state1 = getProcessorState(gen1.getId(), 
Scope.LOCAL);
+        final Map<String, String> state2 = getProcessorState(gen2.getId(), 
Scope.LOCAL);
+
+        final File exportFile = new File("target/st11-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, true, exportFile);
+
+        emptyQueuesAndDeleteProcessGroup(pg);
+
+        final ProcessGroupEntity uploaded = 
getNifiClient().getProcessGroupClient().upload("root", exportFile, 
"ImportedGroup", 0.0, 0.0);
+        final ProcessorEntity imported1 = 
findProcessorByTypeInGroup(uploaded.getId(), "GenerateFlowFile");
+        final ProcessorEntity imported2 = 
findProcessorByTypeInGroup(uploaded.getId(), "StatefulCountProcessor");
+
+        assertEquals(state1.get("count"), getProcessorState(imported1.getId(), 
Scope.LOCAL).get("count"));
+        assertEquals(state2.get("count"), getProcessorState(imported2.getId(), 
Scope.LOCAL).get("count"));
+    }
+
+    @Test
+    public void testExportWithStateEmptyStateNeverRan() throws 
NiFiClientException, IOException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+
+        final File exportFile = new File("target/st23-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, true, exportFile);
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        final VersionedProcessor proc = 
findProcessorByType(snapshot.getFlowContents(), "StatefulCountProcessor");
+        assertNull(proc.getComponentState(), "componentState should be null 
when processor has never run");
+    }
+
+    @Test
+    public void testExportWithStateNoStatefulComponents() throws 
NiFiClientException, IOException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+
+        final File exportFile = new File("target/st24-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, true, exportFile);
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        for (final VersionedProcessor vp : 
snapshot.getFlowContents().getProcessors()) {
+            assertNull(vp.getComponentState(), "Non-stateful processor should 
have null componentState");
+        }
+    }
+
+    @Test
+    public void testReplaceSucceedsWhenFlowExportedWithoutComponentState() 
throws NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity pg = 
getClientUtil().createProcessGroup("TestGroup", "root");
+        final ProcessorEntity stateful = 
getClientUtil().createProcessor("StatefulCountProcessor", pg.getId());
+        final ProcessorEntity terminate = 
getClientUtil().createProcessor("TerminateFlowFile", pg.getId());
+        getClientUtil().createConnection(stateful, terminate, "success");
+
+        getClientUtil().startProcessor(stateful);
+        waitForStatePopulated(stateful.getId(), Scope.LOCAL);
+        getClientUtil().stopProcessor(stateful);
+        getClientUtil().waitForStoppedProcessor(stateful.getId());
+
+        final File exportFile = new File("target/st27-export.json");
+        getNifiClient().getProcessGroupClient().exportProcessGroup(pg.getId(), 
true, false, exportFile);
+
+        getNifiClient().getProcessGroupClient().emptyQueues(pg.getId());
+        waitFor(() -> {
+            try {
+                return 
getNifiClient().getProcessGroupClient().getProcessGroup(pg.getId())
+                        
.getStatus().getAggregateSnapshot().getQueuedCount().equals("0");
+            } catch (final Exception e) {
+                return false;
+            }
+        });
+
+        final RegisteredFlowSnapshot snapshot = MAPPER.readValue(exportFile, 
RegisteredFlowSnapshot.class);
+        replaceProcessGroup(pg, snapshot);
+
+        final ProcessorEntity importedProcessor = 
findProcessorByTypeInGroup(pg.getId(), "StatefulCountProcessor");
+        assertNotNull(importedProcessor);
+    }
+
+    private void waitForStatePopulated(final String processorId, final Scope 
scope) throws InterruptedException {
+        waitFor(() -> {
+            try {
+                final Map<String, String> state = 
getProcessorState(processorId, scope);
+                return state.get("count") != null;
+            } catch (final Exception e) {
+                return false;
+            }
+        });
+    }
+
+    private Map<String, String> getProcessorState(final String processorId, 
final Scope scope) throws NiFiClientException, IOException {
+        final ComponentStateEntity stateEntity = 
getNifiClient().getProcessorClient().getProcessorState(processorId);
+        final ComponentStateDTO componentState = 
stateEntity.getComponentState();
+        final Map<String, String> result = new HashMap<>();
+
+        switch (scope) {
+            case LOCAL:
+                if (componentState != null && componentState.getLocalState() 
!= null && componentState.getLocalState().getState() != null) {
+                    for (final StateEntryDTO entry : 
componentState.getLocalState().getState()) {
+                        result.put(entry.getKey(), entry.getValue());
+                    }
+                }
+                break;
+            case CLUSTER:
+                if (componentState != null && componentState.getClusterState() 
!= null && componentState.getClusterState().getState() != null) {
+                    for (final StateEntryDTO entry : 
componentState.getClusterState().getState()) {
+                        result.put(entry.getKey(), entry.getValue());
+                    }
+                }
+                break;
+        }
+        return result;
+    }
+
+    private VersionedProcessor findProcessorByType(final VersionedProcessGroup 
group, final String typeSuffix) {
+        if (group.getProcessors() != null) {
+            for (final VersionedProcessor vp : group.getProcessors()) {
+                if (vp.getType() != null && vp.getType().endsWith(typeSuffix)) 
{
+                    return vp;
+                }
+            }
+        }
+        return null;
+    }
+
+    private ProcessorEntity findProcessorByTypeInGroup(final String groupId, 
final String typeSuffix) throws NiFiClientException, IOException {
+        return getNifiClient().getFlowClient().getProcessGroup(groupId)
+                .getProcessGroupFlow().getFlow().getProcessors().stream()
+                .filter(pe -> pe.getComponent().getType().endsWith(typeSuffix))
+                .findFirst().orElse(null);
+    }
+
+    private void emptyQueuesAndDeleteProcessGroup(final ProcessGroupEntity pg) 
throws NiFiClientException, IOException, InterruptedException {
+        getNifiClient().getProcessGroupClient().emptyQueues(pg.getId());
+        waitFor(() -> {
+            try {
+                return 
getNifiClient().getProcessGroupClient().getProcessGroup(pg.getId())
+                        
.getStatus().getAggregateSnapshot().getQueuedCount().equals("0");
+            } catch (final Exception e) {
+                return false;
+            }
+        });
+        final ProcessGroupEntity refreshed = 
getNifiClient().getProcessGroupClient().getProcessGroup(pg.getId());
+        refreshed.setDisconnectedNodeAcknowledged(true);
+        getNifiClient().getProcessGroupClient().deleteProcessGroup(refreshed);
+    }
+
+    private void replaceProcessGroup(final ProcessGroupEntity pg, final 
RegisteredFlowSnapshot snapshot) throws NiFiClientException, IOException, 
InterruptedException {
+        final ProcessGroupImportEntity importEntity = new 
ProcessGroupImportEntity();
+        importEntity.setVersionedFlowSnapshot(snapshot);
+        
importEntity.setProcessGroupRevision(getNifiClient().getProcessGroupClient().getProcessGroup(pg.getId()).getRevision());
+
+        final ProcessGroupReplaceRequestEntity replaceRequest =
+                
getNifiClient().getProcessGroupClient().replaceProcessGroup(pg.getId(), 
importEntity);
+        final String requestId = replaceRequest.getRequest().getRequestId();
+
+        waitFor(() -> {
+            try {
+                final ProcessGroupReplaceRequestEntity req =
+                        
getNifiClient().getProcessGroupClient().getProcessGroupReplaceRequest(pg.getId(),
 requestId);
+                return req != null && req.getRequest().isComplete();
+            } catch (final Exception e) {
+                return false;
+            }
+        });
+
+        final ProcessGroupReplaceRequestEntity finalRequest =
+                
getNifiClient().getProcessGroupClient().getProcessGroupReplaceRequest(pg.getId(),
 requestId);
+        assertNull(finalRequest.getRequest().getFailureReason(),
+                "Replace failed: 
%s".formatted(finalRequest.getRequest().getFailureReason()));
+    }
+}
diff --git 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/ProcessGroupClient.java
 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/ProcessGroupClient.java
index 2d530eff05b..a34c27e3d86 100644
--- 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/ProcessGroupClient.java
+++ 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/ProcessGroupClient.java
@@ -68,6 +68,8 @@ public interface ProcessGroupClient {
 
     File exportProcessGroup(String processGroupId, boolean 
includeReferencedServices, File outputFile) throws NiFiClientException, 
IOException;
 
+    File exportProcessGroup(String processGroupId, boolean 
includeReferencedServices, boolean includeComponentState, File outputFile) 
throws NiFiClientException, IOException;
+
     DropRequestEntity emptyQueues(String processGroupId) throws 
NiFiClientException, IOException;
 
     DropRequestEntity getEmptyQueuesRequest(String processGroupId, String 
requestId) throws NiFiClientException, IOException;
diff --git 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyProcessGroupClient.java
 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyProcessGroupClient.java
index 19798f8015e..f5e294e0030 100644
--- 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyProcessGroupClient.java
+++ 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyProcessGroupClient.java
@@ -274,6 +274,12 @@ public class JerseyProcessGroupClient extends 
AbstractJerseyClient implements Pr
 
     @Override
     public File exportProcessGroup(final String processGroupId, final boolean 
includeReferencedServices, final File outputFile) throws NiFiClientException, 
IOException {
+        return exportProcessGroup(processGroupId, includeReferencedServices, 
false, outputFile);
+    }
+
+    @Override
+    public File exportProcessGroup(final String processGroupId, final boolean 
includeReferencedServices, final boolean includeComponentState,
+                                   final File outputFile) throws 
NiFiClientException, IOException {
         if (StringUtils.isBlank(processGroupId)) {
             throw new IllegalArgumentException("Process group id cannot be 
null or blank");
         }
@@ -282,7 +288,8 @@ public class JerseyProcessGroupClient extends 
AbstractJerseyClient implements Pr
             final WebTarget target = processGroupsTarget
                     .path("{id}/download")
                     .resolveTemplate("id", processGroupId)
-                    .queryParam("includeReferencedServices", 
includeReferencedServices);
+                    .queryParam("includeReferencedServices", 
includeReferencedServices)
+                    .queryParam("includeComponentState", 
includeComponentState);
 
             final Response response = getRequestBuilder(target)
                     .accept(MediaType.APPLICATION_JSON)


Reply via email to