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

rfellows 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 751efb02f03 NIFI-16337: Add support for stopping Process Group sources 
(#11678)
751efb02f03 is described below

commit 751efb02f0360f89266d625cbda8e7c3a00465ee
Author: Matt Gilman <[email protected]>
AuthorDate: Tue Sep 15 15:18:43 2026 -0400

    NIFI-16337: Add support for stopping Process Group sources (#11678)
    
    * NIFI-16337: Add support for stopping Process Group sources
    
    - add the Stop sources REST endpoint and cluster response handling
    - expose resolved execution engines on Process Group payloads
    - add Flow Designer actions for stopping source components
    - reject STATELESS targets and skip nested STATELESS groups
    - add backend and frontend test coverage
    
    * NIFI-16337: Addressing review feedback.
---
 .../apache/nifi/web/api/dto/ProcessGroupDTO.java   |  12 +
 .../nifi/web/api/dto/flow/ProcessGroupFlowDTO.java |  12 +
 .../nifi/web/api/entity/ProcessGroupEntity.java    |  13 +
 .../http/StandardHttpResponseMapper.java           |   2 +
 .../http/endpoints/StopSourcesEndpointMerger.java  |  60 +++++
 .../endpoints/StopSourcesEndpointMergerTest.java   |  92 +++++++
 .../org/apache/nifi/web/NiFiServiceFacade.java     |  30 +++
 .../apache/nifi/web/StandardNiFiServiceFacade.java |  61 +++++
 .../java/org/apache/nifi/web/api/FlowResource.java | 119 +++++++++
 .../org/apache/nifi/web/api/dto/DtoFactory.java    |   4 +
 .../org/apache/nifi/web/api/dto/EntityFactory.java |   1 +
 .../nifi/web/StandardNiFiServiceFacadeTest.java    | 290 +++++++++++++++++++++
 .../org/apache/nifi/web/api/TestFlowResource.java  | 251 ++++++++++++++++++
 .../apache/nifi/web/api/dto/DtoFactoryTest.java    | 106 ++++++++
 .../apache/nifi/web/api/dto/EntityFactoryTest.java |  58 +++++
 .../service/canvas-context-menu.service.spec.ts    | 175 +++++++++++++
 .../service/canvas-context-menu.service.ts         |  31 +++
 .../flow-designer/service/canvas-utils.service.ts  |  21 +-
 .../pages/flow-designer/service/flow.service.ts    |  14 +
 .../pages/flow-designer/state/flow/flow.actions.ts |   8 +
 .../flow-designer/state/flow/flow.effects.spec.ts  | 142 +++++++++-
 .../pages/flow-designer/state/flow/flow.effects.ts |  58 +++++
 .../pages/flow-designer/state/flow/flow.reducer.ts |   5 +
 .../flow-designer/state/flow/flow.selectors.ts     |   5 +
 .../app/pages/flow-designer/state/flow/index.ts    |  14 +
 .../tests/system/pg/ClusteredStopSourcesIT.java    | 260 ++++++++++++++++++
 .../org/apache/nifi/toolkit/client/FlowClient.java |  10 +
 .../nifi/toolkit/client/impl/JerseyFlowClient.java |  26 ++
 28 files changed, 1875 insertions(+), 5 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/ProcessGroupDTO.java
 
b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/ProcessGroupDTO.java
index 1a830d5420e..17cfa451c51 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/ProcessGroupDTO.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/ProcessGroupDTO.java
@@ -38,6 +38,7 @@ public class ProcessGroupDTO extends ComponentDTO {
     private String defaultBackPressureDataSizeThreshold;
     private String logFileSuffix;
     private String executionEngine;
+    private String resolvedExecutionEngine;
     private Integer maxConcurrentTasks;
     private String statelessFlowTimeout;
 
@@ -395,6 +396,17 @@ public class ProcessGroupDTO extends ComponentDTO {
         this.executionEngine = executionEngine;
     }
 
+    @Schema(description = "The Execution Engine that will actually run this 
Process Group after resolving INHERITED. Never INHERITED; a root group 
configured as INHERITED resolves to STANDARD.",
+        allowableValues = {"STATELESS", "STANDARD"},
+        accessMode = Schema.AccessMode.READ_ONLY)
+    public String getResolvedExecutionEngine() {
+        return resolvedExecutionEngine;
+    }
+
+    public void setResolvedExecutionEngine(final String 
resolvedExecutionEngine) {
+        this.resolvedExecutionEngine = resolvedExecutionEngine;
+    }
+
     @Schema(description = "If the Process Group is configured to run in using 
the Stateless Engine, represents the current state. Otherwise, will be 
STOPPED.",
             allowableValues = {"STOPPED", "RUNNING"})
     public String getStatelessGroupScheduledState() {
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/flow/ProcessGroupFlowDTO.java
 
b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/flow/ProcessGroupFlowDTO.java
index f2bdaea9db9..a430d88c9d2 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/flow/ProcessGroupFlowDTO.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/flow/ProcessGroupFlowDTO.java
@@ -38,6 +38,7 @@ public class ProcessGroupFlowDTO {
     private FlowBreadcrumbEntity breadcrumb;
     private FlowDTO flow;
     private Date lastRefreshed;
+    private String resolvedExecutionEngine;
 
     /**
      * @return contents of this process group. This field will be populated if 
the request is marked verbose
@@ -130,4 +131,15 @@ public class ProcessGroupFlowDTO {
     public void setParameterContext(ParameterContextReferenceEntity 
parameterContext) {
         this.parameterContext = parameterContext;
     }
+
+    @Schema(description = "The Execution Engine that will actually run this 
Process Group after resolving INHERITED. Never INHERITED; a root group 
configured as INHERITED resolves to STANDARD.",
+        allowableValues = {"STATELESS", "STANDARD"},
+        accessMode = Schema.AccessMode.READ_ONLY)
+    public String getResolvedExecutionEngine() {
+        return resolvedExecutionEngine;
+    }
+
+    public void setResolvedExecutionEngine(final String 
resolvedExecutionEngine) {
+        this.resolvedExecutionEngine = resolvedExecutionEngine;
+    }
 }
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ProcessGroupEntity.java
 
b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ProcessGroupEntity.java
index 70fddd5eaf8..21eb63d51ff 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ProcessGroupEntity.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ProcessGroupEntity.java
@@ -56,6 +56,7 @@ public class ProcessGroupEntity extends ComponentEntity 
implements Permissible<P
     private ParameterContextReferenceEntity parameterContext;
 
     private String processGroupUpdateStrategy;
+    private String resolvedExecutionEngine;
 
     /**
      * The ProcessGroupDTO that is being serialized.
@@ -329,4 +330,16 @@ public class ProcessGroupEntity extends ComponentEntity 
implements Permissible<P
     public void setProcessGroupUpdateStrategy(String 
processGroupUpdateStrategy) {
         this.processGroupUpdateStrategy = processGroupUpdateStrategy;
     }
+
+    @Schema(description = "The Execution Engine that will actually run this 
Process Group after resolving INHERITED. Never INHERITED; a root group 
configured as INHERITED resolves to STANDARD. "
+            + "Promoted onto the entity so it is available when the user 
cannot read the Process Group.",
+            allowableValues = {"STATELESS", "STANDARD"},
+            accessMode = Schema.AccessMode.READ_ONLY)
+    public String getResolvedExecutionEngine() {
+        return resolvedExecutionEngine;
+    }
+
+    public void setResolvedExecutionEngine(final String 
resolvedExecutionEngine) {
+        this.resolvedExecutionEngine = resolvedExecutionEngine;
+    }
 }
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 8fdd9a498e0..a760c045bdf 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
@@ -102,6 +102,7 @@ import 
org.apache.nifi.cluster.coordination.http.endpoints.RuleViolationEndpoint
 import 
org.apache.nifi.cluster.coordination.http.endpoints.RuntimeManifestEndpointMerger;
 import 
org.apache.nifi.cluster.coordination.http.endpoints.SearchUsersEndpointMerger;
 import 
org.apache.nifi.cluster.coordination.http.endpoints.StatusHistoryEndpointMerger;
+import 
org.apache.nifi.cluster.coordination.http.endpoints.StopSourcesEndpointMerger;
 import 
org.apache.nifi.cluster.coordination.http.endpoints.SystemDiagnosticsEndpointMerger;
 import org.apache.nifi.cluster.coordination.http.endpoints.UserEndpointMerger;
 import 
org.apache.nifi.cluster.coordination.http.endpoints.UserGroupEndpointMerger;
@@ -191,6 +192,7 @@ public class StandardHttpResponseMapper implements 
HttpResponseMapper {
         endpointMergers.add(new SystemDiagnosticsEndpointMerger());
         endpointMergers.add(new CountersEndpointMerger());
         endpointMergers.add(new FlowMerger());
+        endpointMergers.add(new StopSourcesEndpointMerger());
         endpointMergers.add(new ProcessorTypesEndpointMerger());
         endpointMergers.add(new ControllerServiceTypesEndpointMerger());
         endpointMergers.add(new ReportingTaskTypesEndpointMerger());
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/StopSourcesEndpointMerger.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/StopSourcesEndpointMerger.java
new file mode 100644
index 00000000000..16b2daafe85
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/StopSourcesEndpointMerger.java
@@ -0,0 +1,60 @@
+/*
+ * 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.manager.NodeResponse;
+import org.apache.nifi.cluster.protocol.NodeIdentifier;
+import org.apache.nifi.web.api.dto.RevisionDTO;
+import org.apache.nifi.web.api.entity.ScheduleComponentsEntity;
+
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+public class StopSourcesEndpointMerger extends 
AbstractSingleEntityEndpoint<ScheduleComponentsEntity> {
+    public static final Pattern STOP_SOURCES_URI_PATTERN = 
Pattern.compile("/nifi-api/flow/process-groups/(?:(?:root)|(?:[a-f0-9\\-]{36}))/sources");
+
+    @Override
+    public boolean canHandle(final URI uri, final String method) {
+        return "PUT".equalsIgnoreCase(method) && 
STOP_SOURCES_URI_PATTERN.matcher(uri.getPath()).matches();
+    }
+
+    @Override
+    protected Class<ScheduleComponentsEntity> getEntityClass() {
+        return ScheduleComponentsEntity.class;
+    }
+
+    @Override
+    protected void mergeResponses(final ScheduleComponentsEntity clientEntity, 
final Map<NodeIdentifier, ScheduleComponentsEntity> entityMap,
+                                  final Set<NodeResponse> successfulResponses, 
final Set<NodeResponse> problematicResponses) {
+        if (clientEntity.getComponents() == null) {
+            clientEntity.setComponents(new HashMap<>());
+        }
+
+        for (final ScheduleComponentsEntity nodeEntity : entityMap.values()) {
+            if (nodeEntity.getComponents() == null) {
+                continue;
+            }
+
+            for (final Map.Entry<String, RevisionDTO> entry : 
nodeEntity.getComponents().entrySet()) {
+                clientEntity.getComponents().putIfAbsent(entry.getKey(), 
entry.getValue());
+            }
+        }
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/StopSourcesEndpointMergerTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/StopSourcesEndpointMergerTest.java
new file mode 100644
index 00000000000..3f0f48b3b6f
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/StopSourcesEndpointMergerTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.protocol.NodeIdentifier;
+import org.apache.nifi.web.api.dto.RevisionDTO;
+import org.apache.nifi.web.api.entity.ScheduleComponentsEntity;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class StopSourcesEndpointMergerTest {
+
+    private static final String GROUP_ID = 
"12345678-1234-1234-1234-123456789012";
+
+    @Test
+    public void testCanHandle() {
+        final StopSourcesEndpointMerger merger = new 
StopSourcesEndpointMerger();
+
+        
assertTrue(merger.canHandle(URI.create("/nifi-api/flow/process-groups/" + 
GROUP_ID + "/sources"), "PUT"));
+        
assertTrue(merger.canHandle(URI.create("/nifi-api/flow/process-groups/root/sources"),
 "PUT"));
+        
assertTrue(merger.canHandle(URI.create("/nifi-api/flow/process-groups/" + 
GROUP_ID + "/sources"), "put"));
+
+        
assertFalse(merger.canHandle(URI.create("/nifi-api/flow/process-groups/" + 
GROUP_ID + "/sources"), "GET"));
+        
assertFalse(merger.canHandle(URI.create("/nifi-api/flow/process-groups/" + 
GROUP_ID), "PUT"));
+        
assertFalse(merger.canHandle(URI.create("/nifi-api/flow/process-groups/" + 
GROUP_ID), "GET"));
+        assertFalse(merger.canHandle(URI.create("/nifi-api/process-groups/" + 
GROUP_ID + "/sources"), "PUT"));
+    }
+
+    @Test
+    public void testMergeResponsesUnionsComponentsAndKeepsClientRevision() {
+        final StopSourcesEndpointMerger merger = new 
StopSourcesEndpointMerger();
+
+        final RevisionDTO clientRevision = revision(1L, "client");
+        final RevisionDTO nodeRevision = revision(2L, "node-2");
+        final RevisionDTO extraRevision = revision(3L, "node-2-extra");
+
+        final ScheduleComponentsEntity clientEntity = new 
ScheduleComponentsEntity();
+        clientEntity.setId(GROUP_ID);
+        clientEntity.setState("STOPPED");
+        clientEntity.setComponents(new HashMap<>(Map.of("source-1", 
clientRevision)));
+
+        final NodeIdentifier node1 = new NodeIdentifier("node1", "localhost", 
8080, "localhost", 8081, "localhost", 8082, 8083, false);
+        final NodeIdentifier node2 = new NodeIdentifier("node2", "localhost", 
8090, "localhost", 8091, "localhost", 8092, 8093, false);
+
+        final ScheduleComponentsEntity node1Entity = new 
ScheduleComponentsEntity();
+        node1Entity.setId(GROUP_ID);
+        node1Entity.setState("STOPPED");
+        node1Entity.setComponents(Map.of("source-1", clientRevision));
+
+        final ScheduleComponentsEntity node2Entity = new 
ScheduleComponentsEntity();
+        node2Entity.setId(GROUP_ID);
+        node2Entity.setState("STOPPED");
+        node2Entity.setComponents(Map.of("source-1", nodeRevision, "source-2", 
extraRevision));
+
+        merger.mergeResponses(clientEntity, Map.of(node1, node1Entity, node2, 
node2Entity), null, null);
+
+        assertEquals(GROUP_ID, clientEntity.getId());
+        assertEquals("STOPPED", clientEntity.getState());
+        assertEquals(2, clientEntity.getComponents().size());
+        assertSame(clientRevision, 
clientEntity.getComponents().get("source-1"));
+        assertSame(extraRevision, 
clientEntity.getComponents().get("source-2"));
+    }
+
+    private static RevisionDTO revision(final long version, final String 
clientId) {
+        final RevisionDTO revision = new RevisionDTO();
+        revision.setVersion(version);
+        revision.setClientId(clientId);
+        return revision;
+    }
+}
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 2313b34f977..2f9b8538d25 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
@@ -387,6 +387,36 @@ public interface NiFiServiceFacade {
      */
     Set<Revision> getRevisionsFromGroup(String groupId, Function<ProcessGroup, 
Set<String>> getComponents);
 
+    /**
+     * Identifies source component identifiers in the given Process Group and 
its Standard-engine descendants.
+     * Sources are processors with no non-loop incoming connection, Remote 
Process Group output ports,
+     * and public input ports with no non-loop incoming connection. Components 
owned by Process Groups
+     * that resolve to the Stateless Execution Engine are excluded.
+     *
+     * @param group the process group to search
+     * @return identifiers of source components
+     */
+    Set<String> findSourceComponentIds(ProcessGroup group);
+
+    /**
+     * Verifies that source components can be stopped in the specified Process 
Group.
+     *
+     * @param groupId process group identifier
+     * @throws IllegalStateException when the Process Group resolves to the 
Stateless Execution Engine
+     */
+    void verifyStopSources(String groupId);
+
+    /**
+     * Verifies that the supplied component identifiers exactly match the 
source components currently identified
+     * in the specified Process Group. This ensures that every cluster node 
operates on the same source components.
+     *
+     * @param groupId process group identifier
+     * @param componentIds source component identifiers
+     * @throws IllegalStateException when the Process Group resolves to the 
Stateless Execution Engine or the supplied
+     * component identifiers do not match the source components currently 
identified
+     */
+    void verifyStopSources(String groupId, Set<String> componentIds);
+
     /**
      * Gets the revisions from the specified snippet.
      *
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 b178d39d676..8e2236e34ce 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
@@ -234,6 +234,7 @@ import 
org.apache.nifi.registry.flow.mapping.InstantiatedVersionedProcessGroup;
 import 
org.apache.nifi.registry.flow.mapping.InstantiatedVersionedRemoteGroupPort;
 import org.apache.nifi.registry.flow.mapping.VersionedComponentFlowMapper;
 import org.apache.nifi.registry.flow.mapping.VersionedComponentStateLookup;
+import org.apache.nifi.remote.PublicPort;
 import org.apache.nifi.remote.RemoteGroupPort;
 import org.apache.nifi.reporting.Bulletin;
 import org.apache.nifi.reporting.BulletinQuery;
@@ -242,6 +243,7 @@ import org.apache.nifi.reporting.ComponentType;
 import org.apache.nifi.reporting.ReportingTask;
 import org.apache.nifi.reporting.VerifiableReportingTask;
 import org.apache.nifi.util.BundleUtils;
+import org.apache.nifi.util.Connectables;
 import org.apache.nifi.util.FlowDifferenceFilters;
 import org.apache.nifi.util.NiFiProperties;
 import org.apache.nifi.util.StringUtils;
@@ -595,6 +597,65 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
         return componentIds.stream().map(id -> 
revisionManager.getRevision(id)).collect(Collectors.toSet());
     }
 
+    @Override
+    public Set<String> findSourceComponentIds(final ProcessGroup group) {
+        final Set<String> sourceIds = new LinkedHashSet<>();
+
+        for (final ProcessorNode processor : group.findAllProcessors()) {
+            if (processor.getProcessGroup().resolveExecutionEngine() == 
ExecutionEngine.STANDARD
+                    && ProcessGroup.STOP_PROCESSORS_FILTER.test(processor)
+                    && !Connectables.hasNonLoopConnection(processor)) {
+                sourceIds.add(processor.getIdentifier());
+            }
+        }
+
+        for (final RemoteProcessGroup remoteProcessGroup : 
group.findAllRemoteProcessGroups()) {
+            if (remoteProcessGroup.getProcessGroup().resolveExecutionEngine() 
== ExecutionEngine.STATELESS) {
+                continue;
+            }
+
+            for (final RemoteGroupPort remotePort : 
remoteProcessGroup.getOutputPorts()) {
+                if (ProcessGroup.STOP_PORTS_FILTER.test(remotePort)) {
+                    sourceIds.add(remotePort.getIdentifier());
+                }
+            }
+        }
+
+        for (final Port port : group.findAllInputPorts()) {
+            if (port.getProcessGroup().resolveExecutionEngine() == 
ExecutionEngine.STANDARD
+                    && port instanceof PublicPort
+                    && ProcessGroup.STOP_PORTS_FILTER.test(port)
+                    && !Connectables.hasNonLoopConnection(port)) {
+                sourceIds.add(port.getIdentifier());
+            }
+        }
+
+        return sourceIds;
+    }
+
+    @Override
+    public void verifyStopSources(final String groupId) {
+        final ProcessGroup group = processGroupDAO.getProcessGroup(groupId);
+        verifyStopSources(group);
+    }
+
+    @Override
+    public void verifyStopSources(final String groupId, final Set<String> 
componentIds) {
+        final ProcessGroup group = processGroupDAO.getProcessGroup(groupId);
+        verifyStopSources(group);
+
+        final Set<String> sourceComponentIds = findSourceComponentIds(group);
+        if (!sourceComponentIds.equals(componentIds)) {
+            throw new IllegalStateException("Source components changed while 
processing the request; refresh and retry");
+        }
+    }
+
+    private static void verifyStopSources(final ProcessGroup group) {
+        if (group.resolveExecutionEngine() == ExecutionEngine.STATELESS) {
+            throw new IllegalStateException("Cannot stop sources in a Process 
Group that resolves to the Stateless Execution Engine");
+        }
+    }
+
     @Override
     public Set<Revision> getRevisionsFromSnippet(final String snippetId) {
         final Snippet snippet = snippetDAO.getSnippet(snippetId);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java
index 753c6c66a97..a77f79fdba7 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java
@@ -1217,6 +1217,125 @@ public class FlowResource extends ApplicationResource {
         );
     }
 
+    /**
+     * Stops source components in the specified process group and its 
Standard-engine descendants. Sources are
+     * processors with no non-loop incoming connection, Remote Process Group 
output ports, and public input ports
+     * with no non-loop incoming connection. The operation is rejected when 
the specified group resolves to the
+     * Stateless Execution Engine.
+     *
+     * @param id The id of the process group.
+     * @param requestScheduleComponentsEntity A scheduleComponentsEntity with 
state STOPPED.
+     * @return A scheduleComponentsEntity identifying the components that were 
stopped.
+     */
+    @PUT
+    @Consumes(MediaType.APPLICATION_JSON)
+    @Produces(MediaType.APPLICATION_JSON)
+    @Path("process-groups/{id}/sources")
+    @Operation(
+            summary = "Stop source components in the specified Process Group.",
+            description = "All source components included in the operation 
must be authorized. If any source is unauthorized, no components are stopped.",
+            responses = {
+                    @ApiResponse(responseCode = "200", content = 
@Content(schema = @Schema(implementation = ScheduleComponentsEntity.class))),
+                    @ApiResponse(responseCode = "400", description = "NiFi was 
unable to complete the request because it was invalid. The request should not 
be retried without modification."),
+                    @ApiResponse(responseCode = "401", description = "Client 
could not be authenticated."),
+                    @ApiResponse(responseCode = "403", description = "Client 
is not authorized to make this request."),
+                    @ApiResponse(responseCode = "404", description = "The 
specified resource could not be found."),
+                    @ApiResponse(responseCode = "409", description = "The 
request was valid but NiFi was not in the appropriate state to process it.")
+            },
+            security = {
+                    @SecurityRequirement(name = "Read - /flow"),
+                    @SecurityRequirement(name = "Write - 
/{component-type}/{uuid} or /operation/{component-type}/{uuid} - For every 
source component being stopped")
+            }
+    )
+    public Response stopSources(
+            @Parameter(
+                    description = "The process group id.",
+                    required = true
+            )
+            @PathParam("id") final String id,
+            @Parameter(
+                    description = "The request to stop sources. If the 
components in the request are not specified, all source components are 
identified.",
+                    required = true
+            ) final ScheduleComponentsEntity requestScheduleComponentsEntity) {
+
+        if (requestScheduleComponentsEntity == null) {
+            throw new IllegalArgumentException("Schedule Component must be 
specified.");
+        }
+
+        if (!id.equals(requestScheduleComponentsEntity.getId())) {
+            throw new IllegalArgumentException(String.format("The process 
group id (%s) in the request body does "
+                    + "not equal the process group id of the requested 
resource (%s).", requestScheduleComponentsEntity.getId(), id));
+        }
+
+        if 
(!ScheduledState.STOPPED.name().equals(requestScheduleComponentsEntity.getState()))
 {
+            throw new IllegalArgumentException("The scheduled state must be 
STOPPED.");
+        }
+
+        authorizeFlow();
+        serviceFacade.verifyStopSources(id);
+
+        if (requestScheduleComponentsEntity.getComponents() == null) {
+            final Set<Revision> revisions = 
serviceFacade.getRevisionsFromGroup(id, serviceFacade::findSourceComponentIds);
+            final Map<String, RevisionDTO> componentsToStop = new HashMap<>();
+            for (final Revision revision : revisions) {
+                final RevisionDTO dto = new RevisionDTO();
+                dto.setClientId(revision.getClientId());
+                dto.setVersion(revision.getVersion());
+                componentsToStop.put(revision.getComponentId(), dto);
+            }
+
+            requestScheduleComponentsEntity.setComponents(componentsToStop);
+        }
+
+        final Map<String, RevisionDTO> requestComponentsToStop = 
requestScheduleComponentsEntity.getComponents();
+        final Set<String> requestComponentIds = 
Set.copyOf(requestComponentsToStop.keySet());
+
+        serviceFacade.authorizeAccess(lookup -> {
+            for (final String componentId : requestComponentIds) {
+                final Authorizable connectable = 
lookup.getLocalConnectable(componentId);
+                OperationAuthorizable.authorizeOperation(connectable, 
authorizer, NiFiUserUtils.getNiFiUser());
+            }
+        });
+
+        serviceFacade.verifyStopSources(id, requestComponentIds);
+
+        if (isReplicateRequest()) {
+            return replicate(HttpMethod.PUT, requestScheduleComponentsEntity);
+        } else if (isDisconnectedFromCluster()) {
+            
verifyDisconnectedNodeModification(requestScheduleComponentsEntity.isDisconnectedNodeAcknowledged());
+        }
+
+        final Map<String, Revision> requestComponentRevisions =
+                
requestComponentsToStop.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,
 e -> getRevision(e.getValue(), e.getKey())));
+        final Set<Revision> requestRevisions = new 
HashSet<>(requestComponentRevisions.values());
+
+        return withWriteLock(
+                serviceFacade,
+                requestScheduleComponentsEntity,
+                requestRevisions,
+                lookup -> {
+                    authorizeFlow();
+
+                    requestComponentsToStop.keySet().forEach(componentId -> {
+                        final Authorizable connectable = 
lookup.getLocalConnectable(componentId);
+                        OperationAuthorizable.authorizeOperation(connectable, 
authorizer, NiFiUserUtils.getNiFiUser());
+                    });
+                },
+                () -> {
+                    serviceFacade.verifyStopSources(id, requestComponentIds);
+                    serviceFacade.verifyScheduleComponents(id, 
ScheduledState.STOPPED, requestComponentIds);
+                },
+                (revisions, scheduleComponentsEntity) -> {
+                    final Map<String, RevisionDTO> componentsToStop = 
scheduleComponentsEntity.getComponents();
+                    final Map<String, Revision> componentRevisions =
+                            
componentsToStop.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,
 e -> getRevision(e.getValue(), e.getKey())));
+                    final ScheduleComponentsEntity entity = 
serviceFacade.scheduleComponents(id, ScheduledState.STOPPED, 
componentRevisions);
+                    entity.setComponents(componentsToStop);
+                    return generateOkResponse(entity).build();
+                }
+        );
+    }
+
     @PUT
     @Consumes(MediaType.APPLICATION_JSON)
     @Produces(MediaType.APPLICATION_JSON)
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
index 46e8ab35d7a..bc24e38ecda 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
@@ -2568,6 +2568,8 @@ public final class DtoFactory {
             dto.setParentGroupId(parent.getIdentifier());
         }
 
+        dto.setResolvedExecutionEngine(group.resolveExecutionEngine().name());
+
         final ParameterContext parameterContext = group.getParameterContext();
         if (parameterContext != null) {
             
dto.setParameterContext(entityFactory.createParameterReferenceEntity(createParameterContextReference(parameterContext),
 createPermissionsDto(parameterContext)));
@@ -2834,6 +2836,7 @@ public final class DtoFactory {
         dto.setLogFileSuffix(group.getLogFileSuffix());
         
dto.setStatelessGroupScheduledState(group.getStatelessScheduledState().name());
         dto.setExecutionEngine(group.getExecutionEngine().name());
+        dto.setResolvedExecutionEngine(group.resolveExecutionEngine().name());
         dto.setMaxConcurrentTasks(group.getMaxConcurrentTasks());
         dto.setStatelessFlowTimeout(group.getStatelessFlowTimeout());
 
@@ -4825,6 +4828,7 @@ public final class DtoFactory {
         
copy.setDefaultBackPressureDataSizeThreshold(original.getDefaultBackPressureDataSizeThreshold());
         copy.setLogFileSuffix(original.getLogFileSuffix());
         copy.setExecutionEngine(original.getExecutionEngine());
+        copy.setResolvedExecutionEngine(original.getResolvedExecutionEngine());
         copy.setMaxConcurrentTasks(original.getMaxConcurrentTasks());
         copy.setStatelessFlowTimeout(original.getStatelessFlowTimeout());
 
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/EntityFactory.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/EntityFactory.java
index be52686de48..f3733142082 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/EntityFactory.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/EntityFactory.java
@@ -320,6 +320,7 @@ public final class EntityFactory {
             entity.setStaleCount(dto.getStaleCount());
             
entity.setLocallyModifiedAndStaleCount(dto.getLocallyModifiedAndStaleCount());
             entity.setSyncFailureCount(dto.getSyncFailureCount());
+            
entity.setResolvedExecutionEngine(dto.getResolvedExecutionEngine());
 
             final ParameterContextReferenceEntity parameterContextReference = 
dto.getParameterContext();
             if (parameterContextReference != null) {
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
index 079f8cf8629..47f729e1763 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
@@ -56,6 +56,9 @@ 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.ValidationStatus;
+import org.apache.nifi.connectable.Connectable;
+import org.apache.nifi.connectable.Connection;
+import org.apache.nifi.connectable.Port;
 import org.apache.nifi.controller.ClusterTopologyProvider;
 import org.apache.nifi.controller.ControllerService;
 import org.apache.nifi.controller.Counter;
@@ -114,6 +117,8 @@ import 
org.apache.nifi.registry.flow.diff.StaticDifferenceDescriptor;
 import org.apache.nifi.registry.flow.mapping.FlowMappingOptions;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedProcessGroup;
 import org.apache.nifi.registry.flow.mapping.VersionedComponentFlowMapper;
+import org.apache.nifi.remote.PublicPort;
+import org.apache.nifi.remote.RemoteGroupPort;
 import org.apache.nifi.reporting.Bulletin;
 import org.apache.nifi.reporting.BulletinFactory;
 import org.apache.nifi.reporting.BulletinQuery;
@@ -3260,6 +3265,291 @@ public class StandardNiFiServiceFacadeTest {
         verify(assetManager).deleteAsset(ASSET_ID);
     }
 
+    @Test
+    public void 
testFindSourceComponentIdsIncludesProcessorWithNoIncomingConnections() {
+        final ProcessorNode processor = sourceProcessor("source-processor");
+        final ProcessGroup group = sourceProcessGroup(List.of(processor), 
List.of(), List.of());
+
+        assertEquals(Set.of("source-processor"), 
serviceFacade.findSourceComponentIds(group));
+    }
+
+    @Test
+    public void testFindSourceComponentIdsIncludesProcessorWithSelfLoop() {
+        final ProcessorNode processor = sourceProcessor("self-loop-processor");
+        final Connection selfLoop = connectionFrom(processor);
+        when(processor.getIncomingConnections()).thenReturn(List.of(selfLoop));
+        final ProcessGroup group = sourceProcessGroup(List.of(processor), 
List.of(), List.of());
+
+        assertEquals(Set.of("self-loop-processor"), 
serviceFacade.findSourceComponentIds(group));
+    }
+
+    @Test
+    public void 
testFindSourceComponentIdsExcludesProcessorWithIncomingConnectionFromOtherComponent()
 {
+        final ProcessorNode upstream = sourceProcessor("upstream");
+        final ProcessorNode downstream = sourceProcessor("downstream");
+        final Connection incomingConnection = connectionFrom(upstream);
+        
when(downstream.getIncomingConnections()).thenReturn(List.of(incomingConnection));
+        final ProcessGroup group = sourceProcessGroup(List.of(upstream, 
downstream), List.of(), List.of());
+
+        assertEquals(Set.of("upstream"), 
serviceFacade.findSourceComponentIds(group));
+    }
+
+    @Test
+    public void 
testFindSourceComponentIdsIncludesNestedProcessGroupProcessors() {
+        final ProcessorNode parentSource = sourceProcessor("parent-source");
+        final ProcessorNode nestedSource = sourceProcessor("nested-source");
+        final ProcessGroup group = sourceProcessGroup(List.of(parentSource, 
nestedSource), List.of(), List.of());
+
+        assertEquals(Set.of("parent-source", "nested-source"), 
serviceFacade.findSourceComponentIds(group));
+    }
+
+    @Test
+    public void 
testFindSourceComponentIdsExcludesSourcesInStatelessProcessGroups() {
+        final ProcessorNode statelessProcessor = 
sourceProcessor("stateless-processor");
+        final RemoteGroupPort statelessRemoteOutput = 
remotePort("stateless-remote-output");
+        final RemoteProcessGroup statelessRemoteProcessGroup = 
mock(RemoteProcessGroup.class);
+        
when(statelessRemoteProcessGroup.getOutputPorts()).thenReturn(Set.of(statelessRemoteOutput));
+        final PublicPort statelessPublicInput = 
sourcePublicPort("stateless-public-input");
+        final ProcessGroup group = sourceProcessGroup(
+                List.of(statelessProcessor),
+                List.of(statelessRemoteProcessGroup),
+                List.of(statelessPublicInput)
+        );
+        final ProcessGroup statelessGroup = mock(ProcessGroup.class);
+        
when(statelessGroup.resolveExecutionEngine()).thenReturn(ExecutionEngine.STATELESS);
+        when(statelessProcessor.getProcessGroup()).thenReturn(statelessGroup);
+        
when(statelessRemoteProcessGroup.getProcessGroup()).thenReturn(statelessGroup);
+        
when(statelessPublicInput.getProcessGroup()).thenReturn(statelessGroup);
+
+        assertTrue(serviceFacade.findSourceComponentIds(group).isEmpty());
+    }
+
+    @Test
+    public void testVerifyStopSourcesRejectsStatelessProcessGroup() {
+        final ProcessGroup group = mock(ProcessGroup.class);
+        
when(group.resolveExecutionEngine()).thenReturn(ExecutionEngine.STATELESS);
+        
when(processGroupDAO.getProcessGroup("stateless-group")).thenReturn(group);
+
+        assertThrows(IllegalStateException.class, () -> 
serviceFacade.verifyStopSources("stateless-group"));
+    }
+
+    @Test
+    public void testVerifyStopSourcesAllowsStandardProcessGroup() {
+        final ProcessGroup group = mock(ProcessGroup.class);
+        
when(group.resolveExecutionEngine()).thenReturn(ExecutionEngine.STANDARD);
+        
when(processGroupDAO.getProcessGroup("standard-group")).thenReturn(group);
+
+        serviceFacade.verifyStopSources("standard-group");
+    }
+
+    @Test
+    public void testVerifyStopSourcesAllowsMatchingSourceComponents() {
+        final ProcessorNode sourceProcessor = 
sourceProcessor("source-processor");
+        final ProcessGroup group = 
sourceProcessGroup(List.of(sourceProcessor), List.of(), List.of());
+        
when(processGroupDAO.getProcessGroup("standard-group")).thenReturn(group);
+
+        serviceFacade.verifyStopSources("standard-group", 
Set.of("source-processor"));
+    }
+
+    @Test
+    public void testVerifyStopSourcesRejectsMissingSourceComponent() {
+        final ProcessorNode sourceProcessor = 
sourceProcessor("source-processor");
+        final ProcessGroup group = 
sourceProcessGroup(List.of(sourceProcessor), List.of(), List.of());
+        
when(processGroupDAO.getProcessGroup("standard-group")).thenReturn(group);
+
+        assertThrows(IllegalStateException.class, () -> 
serviceFacade.verifyStopSources("standard-group", Set.of()));
+    }
+
+    @Test
+    public void testVerifyStopSourcesRejectsNonSourceComponent() {
+        final ProcessorNode sourceProcessor = 
sourceProcessor("source-processor");
+        final ProcessorNode downstreamProcessor = 
sourceProcessor("downstream-processor");
+        final Connection incomingConnection = connectionFrom(sourceProcessor);
+        
when(downstreamProcessor.getIncomingConnections()).thenReturn(List.of(incomingConnection));
+        final ProcessGroup group = sourceProcessGroup(List.of(sourceProcessor, 
downstreamProcessor), List.of(), List.of());
+        
when(processGroupDAO.getProcessGroup("standard-group")).thenReturn(group);
+
+        assertThrows(IllegalStateException.class,
+                () -> serviceFacade.verifyStopSources("standard-group", 
Set.of("source-processor", "downstream-processor")));
+    }
+
+    @Test
+    public void testVerifyStopSourcesRejectsComponentInStatelessDescendant() {
+        final ProcessorNode statelessProcessor = 
sourceProcessor("stateless-processor");
+        final ProcessGroup group = 
sourceProcessGroup(List.of(statelessProcessor), List.of(), List.of());
+        final ProcessGroup statelessGroup = mock(ProcessGroup.class);
+        
when(statelessGroup.resolveExecutionEngine()).thenReturn(ExecutionEngine.STATELESS);
+        when(statelessProcessor.getProcessGroup()).thenReturn(statelessGroup);
+        
when(processGroupDAO.getProcessGroup("standard-group")).thenReturn(group);
+
+        assertThrows(IllegalStateException.class,
+                () -> serviceFacade.verifyStopSources("standard-group", 
Set.of("stateless-processor")));
+    }
+
+    @Test
+    public void testFindSourceComponentIdsExcludesNonRunningSourceProcessor() {
+        final ProcessorNode nonRunningSource = 
sourceProcessor("non-running-source", false);
+        final ProcessGroup group = 
sourceProcessGroup(List.of(nonRunningSource), List.of(), List.of());
+
+        assertTrue(serviceFacade.findSourceComponentIds(group).isEmpty());
+    }
+
+    @Test
+    public void 
testFindSourceComponentIdsExcludesNonTransmittingRemoteOutputPort() {
+        final RemoteGroupPort remoteOutput = remotePort("remote-output", 
ScheduledState.STOPPED);
+        final RemoteProcessGroup remoteProcessGroup = 
mock(RemoteProcessGroup.class);
+        
when(remoteProcessGroup.getOutputPorts()).thenReturn(Set.of(remoteOutput));
+        when(remoteProcessGroup.getInputPorts()).thenReturn(Set.of());
+        final ProcessGroup group = sourceProcessGroup(List.of(), 
List.of(remoteProcessGroup), List.of());
+
+        assertTrue(serviceFacade.findSourceComponentIds(group).isEmpty());
+    }
+
+    @Test
+    public void testFindSourceComponentIdsExcludesStoppedPublicInputPort() {
+        final PublicPort publicInput = sourcePublicPort("public-input", 
ScheduledState.STOPPED);
+        final ProcessGroup group = sourceProcessGroup(List.of(), List.of(), 
List.of(publicInput));
+
+        assertTrue(serviceFacade.findSourceComponentIds(group).isEmpty());
+    }
+
+    @Test
+    public void 
testFindSourceComponentIdsIncludesRemoteOutputPortAndExcludesRemoteInputPort() {
+        final RemoteGroupPort remoteOutput = remotePort("remote-output");
+        final RemoteGroupPort remoteInput = remotePort("remote-input");
+        final RemoteProcessGroup remoteProcessGroup = 
mock(RemoteProcessGroup.class);
+        when(remoteProcessGroup.getIdentifier()).thenReturn("rpg");
+        
when(remoteProcessGroup.getOutputPorts()).thenReturn(Set.of(remoteOutput));
+        
when(remoteProcessGroup.getInputPorts()).thenReturn(Set.of(remoteInput));
+        final ProcessGroup group = sourceProcessGroup(List.of(), 
List.of(remoteProcessGroup), List.of());
+
+        final Set<String> sourceIds = 
serviceFacade.findSourceComponentIds(group);
+        assertEquals(Set.of("remote-output"), sourceIds);
+        assertFalse(sourceIds.contains("rpg"));
+        assertFalse(sourceIds.contains("remote-input"));
+    }
+
+    @Test
+    public void testFindSourceComponentIdsExcludesLocalPorts() {
+        final Port localInput = sourcePort("local-input");
+        final Port localOutput = sourcePort("local-output");
+        final ProcessGroup group = sourceProcessGroup(List.of(), List.of(), 
List.of(localInput));
+        when(group.findAllOutputPorts()).thenReturn(List.of(localOutput));
+
+        assertTrue(serviceFacade.findSourceComponentIds(group).isEmpty());
+    }
+
+    @Test
+    public void 
testFindSourceComponentIdsIncludesPublicInputPortWithNoIncomingConnections() {
+        final PublicPort publicInput = sourcePublicPort("public-input");
+        final ProcessGroup group = sourceProcessGroup(List.of(), List.of(), 
List.of(publicInput));
+
+        assertEquals(Set.of("public-input"), 
serviceFacade.findSourceComponentIds(group));
+    }
+
+    @Test
+    public void 
testFindSourceComponentIdsExcludesPublicInputPortWithIncomingConnectionFromParent()
 {
+        final Connectable parentOutput = sourceProcessor("parent-output");
+        final PublicPort publicInput = sourcePublicPort("nested-public-input");
+        final Connection incomingConnection = connectionFrom(parentOutput);
+        
when(publicInput.getIncomingConnections()).thenReturn(List.of(incomingConnection));
+        final ProcessGroup group = sourceProcessGroup(List.of(), List.of(), 
List.of(publicInput));
+
+        assertTrue(serviceFacade.findSourceComponentIds(group).isEmpty());
+    }
+
+    @Test
+    public void testFindSourceComponentIdsExcludesPublicOutputPort() {
+        final PublicPort publicOutput = sourcePublicPort("public-output");
+        final ProcessGroup group = sourceProcessGroup(List.of(), List.of(), 
List.of());
+        when(group.findAllOutputPorts()).thenReturn(List.of(publicOutput));
+
+        assertTrue(serviceFacade.findSourceComponentIds(group).isEmpty());
+    }
+
+    @Test
+    public void testFindSourceComponentIdsMixOfSourcesAndNonSources() {
+        final ProcessorNode sourceProcessor = 
sourceProcessor("source-processor");
+        final ProcessorNode downstream = sourceProcessor("downstream");
+        final Connection incomingConnection = connectionFrom(sourceProcessor);
+        
when(downstream.getIncomingConnections()).thenReturn(List.of(incomingConnection));
+
+        final RemoteGroupPort remoteOutput = remotePort("remote-output");
+        final RemoteGroupPort remoteInput = remotePort("remote-input");
+        final RemoteProcessGroup remoteProcessGroup = 
mock(RemoteProcessGroup.class);
+        when(remoteProcessGroup.getIdentifier()).thenReturn("rpg");
+        
when(remoteProcessGroup.getOutputPorts()).thenReturn(Set.of(remoteOutput));
+        
when(remoteProcessGroup.getInputPorts()).thenReturn(Set.of(remoteInput));
+
+        final PublicPort publicInput = sourcePublicPort("public-input");
+        final Port localInput = sourcePort("local-input");
+
+        final ProcessGroup group = sourceProcessGroup(List.of(sourceProcessor, 
downstream), List.of(remoteProcessGroup), List.of(publicInput, localInput));
+
+        assertEquals(Set.of("source-processor", "remote-output", 
"public-input"), serviceFacade.findSourceComponentIds(group));
+    }
+
+    private static ProcessGroup sourceProcessGroup(final List<ProcessorNode> 
processors, final List<RemoteProcessGroup> remoteProcessGroups, final 
List<Port> inputPorts) {
+        final ProcessGroup group = mock(ProcessGroup.class);
+        
when(group.resolveExecutionEngine()).thenReturn(ExecutionEngine.STANDARD);
+        when(group.findAllProcessors()).thenReturn(processors);
+        
when(group.findAllRemoteProcessGroups()).thenReturn(remoteProcessGroups);
+        when(group.findAllInputPorts()).thenReturn(inputPorts);
+        when(group.findAllOutputPorts()).thenReturn(Collections.emptyList());
+        processors.forEach(processor -> 
when(processor.getProcessGroup()).thenReturn(group));
+        remoteProcessGroups.forEach(remoteProcessGroup -> 
when(remoteProcessGroup.getProcessGroup()).thenReturn(group));
+        inputPorts.forEach(inputPort -> 
when(inputPort.getProcessGroup()).thenReturn(group));
+        return group;
+    }
+
+    private static ProcessorNode sourceProcessor(final String identifier) {
+        return sourceProcessor(identifier, true);
+    }
+
+    private static ProcessorNode sourceProcessor(final String identifier, 
final boolean running) {
+        final ProcessorNode processor = mock(ProcessorNode.class);
+        when(processor.getIdentifier()).thenReturn(identifier);
+        
when(processor.getIncomingConnections()).thenReturn(Collections.emptyList());
+        when(processor.isRunning()).thenReturn(running);
+        return processor;
+    }
+
+    private static Port sourcePort(final String identifier) {
+        final Port port = mock(Port.class);
+        when(port.getIdentifier()).thenReturn(identifier);
+        
when(port.getIncomingConnections()).thenReturn(Collections.emptyList());
+        return port;
+    }
+
+    private static PublicPort sourcePublicPort(final String identifier) {
+        return sourcePublicPort(identifier, ScheduledState.RUNNING);
+    }
+
+    private static PublicPort sourcePublicPort(final String identifier, final 
ScheduledState scheduledState) {
+        final PublicPort port = mock(PublicPort.class);
+        when(port.getIdentifier()).thenReturn(identifier);
+        
when(port.getIncomingConnections()).thenReturn(Collections.emptyList());
+        when(port.getScheduledState()).thenReturn(scheduledState);
+        return port;
+    }
+
+    private static RemoteGroupPort remotePort(final String identifier) {
+        return remotePort(identifier, ScheduledState.RUNNING);
+    }
+
+    private static RemoteGroupPort remotePort(final String identifier, final 
ScheduledState scheduledState) {
+        final RemoteGroupPort port = mock(RemoteGroupPort.class);
+        when(port.getIdentifier()).thenReturn(identifier);
+        when(port.getScheduledState()).thenReturn(scheduledState);
+        return port;
+    }
+
+    private static Connection connectionFrom(final Connectable source) {
+        final Connection connection = mock(Connection.class);
+        when(connection.getSource()).thenReturn(source);
+        return connection;
+    }
+
     private Asset createAsset(final String assetId, final String ownerId) {
         final Asset asset = mock(Asset.class);
         when(asset.getIdentifier()).thenReturn(assetId);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestFlowResource.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestFlowResource.java
index 8356406c31a..3841ed70afe 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestFlowResource.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestFlowResource.java
@@ -26,15 +26,22 @@ import com.fasterxml.jackson.databind.module.SimpleModule;
 import io.prometheus.client.Collector.MetricFamilySamples.Sample;
 import io.prometheus.client.CollectorRegistry;
 import io.prometheus.client.exporter.common.TextFormat;
+import jakarta.ws.rs.HttpMethod;
 import jakarta.ws.rs.core.MediaType;
 import jakarta.ws.rs.core.Response;
 import jakarta.ws.rs.core.StreamingOutput;
 import org.apache.nifi.authorization.AccessDeniedException;
+import org.apache.nifi.authorization.AuthorizableLookup;
 import org.apache.nifi.authorization.AuthorizeAccess;
+import org.apache.nifi.authorization.Authorizer;
+import org.apache.nifi.authorization.RequestAction;
+import org.apache.nifi.authorization.resource.Authorizable;
+import org.apache.nifi.authorization.user.NiFiUser;
 import org.apache.nifi.components.ValidationResult;
 import org.apache.nifi.components.validation.DisabledServiceValidationResult;
 import org.apache.nifi.connectable.Port;
 import org.apache.nifi.controller.ProcessorNode;
+import org.apache.nifi.controller.ScheduledState;
 import org.apache.nifi.controller.service.ControllerServiceNode;
 import org.apache.nifi.controller.status.ProcessGroupStatus;
 import org.apache.nifi.controller.status.ProcessingPerformanceStatus;
@@ -52,12 +59,15 @@ import org.apache.nifi.registry.flow.FlowVersionLocation;
 import org.apache.nifi.util.NiFiProperties;
 import org.apache.nifi.web.NiFiServiceFacade;
 import org.apache.nifi.web.ResourceNotFoundException;
+import org.apache.nifi.web.Revision;
 import org.apache.nifi.web.api.dto.ComponentDifferenceDTO;
 import org.apache.nifi.web.api.dto.DifferenceDTO;
+import org.apache.nifi.web.api.dto.RevisionDTO;
 import org.apache.nifi.web.api.entity.ActivateControllerServicesEntity;
 import org.apache.nifi.web.api.entity.ClearBulletinsForGroupRequestEntity;
 import org.apache.nifi.web.api.entity.ConnectorEntity;
 import org.apache.nifi.web.api.entity.FlowComparisonEntity;
+import org.apache.nifi.web.api.entity.ScheduleComponentsEntity;
 import org.apache.nifi.web.api.request.FlowMetricsProducer;
 import org.apache.nifi.web.api.request.FlowMetricsReportingStrategy;
 import org.jetbrains.annotations.NotNull;
@@ -95,13 +105,18 @@ import static 
org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.anySet;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.nullable;
 import static org.mockito.Mockito.any;
 import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doNothing;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.lenient;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
@@ -145,6 +160,9 @@ public class TestFlowResource {
     @Mock
     private NiFiProperties properties;
 
+    @Mock
+    private Authorizer authorizer;
+
     @Mock
     private ConnectorResource connectorResource;
 
@@ -152,6 +170,7 @@ public class TestFlowResource {
     public void setUp() {
         lenient().when(properties.isNode()).thenReturn(Boolean.FALSE);
         resource.properties = properties;
+        resource.setAuthorizer(authorizer);
         resource.setConnectorResource(connectorResource);
     }
 
@@ -616,6 +635,238 @@ public class TestFlowResource {
         assertEquals(5, componentIds.size(), "Should have exactly 5 authorized 
components");
     }
 
+    @Test
+    public void testStopSourcesUsesFacadeToIdentifySources() {
+        final ScheduleComponentsEntity entity = new ScheduleComponentsEntity();
+        entity.setId(PROCESS_GROUP_ID);
+        entity.setState(ScheduledState.STOPPED.name());
+
+        when(properties.isNode()).thenReturn(false);
+        resource.httpServletRequest = new MockHttpServletRequest();
+
+        final ProcessGroup processGroup = mock(ProcessGroup.class);
+        final Set<String> identifiedSources = Set.of("source-processor", 
"remote-output", "public-input");
+        
when(serviceFacade.findSourceComponentIds(processGroup)).thenReturn(identifiedSources);
+
+        final ArgumentCaptor<Function<ProcessGroup, Set<String>>> 
revisionsCaptor = ArgumentCaptor.captor();
+        when(serviceFacade.getRevisionsFromGroup(eq(PROCESS_GROUP_ID), 
revisionsCaptor.capture())).thenReturn(Set.of());
+        when(serviceFacade.scheduleComponents(eq(PROCESS_GROUP_ID), 
eq(ScheduledState.STOPPED), any())).thenReturn(entity);
+
+        final Response response = resource.stopSources(PROCESS_GROUP_ID, 
entity);
+
+        assertNotNull(response);
+        assertEquals(HttpURLConnection.HTTP_OK, response.getStatus());
+        assertTrue(entity.getComponents().isEmpty());
+        assertEquals(identifiedSources, 
revisionsCaptor.getValue().apply(processGroup));
+    }
+
+    @Test
+    public void testStopSourcesUnauthorizedFlowDoesNotDiscoverOrSchedule() {
+        final ScheduleComponentsEntity entity = new ScheduleComponentsEntity();
+        entity.setId(PROCESS_GROUP_ID);
+        entity.setState(ScheduledState.STOPPED.name());
+
+        resource.httpServletRequest = new MockHttpServletRequest();
+
+        doThrow(new 
AccessDeniedException("denied")).when(serviceFacade).authorizeAccess(any());
+
+        assertThrows(AccessDeniedException.class, () -> 
resource.stopSources(PROCESS_GROUP_ID, entity));
+
+        verify(serviceFacade, never()).verifyStopSources(anyString());
+        verify(serviceFacade, never()).getRevisionsFromGroup(anyString(), 
any());
+        verify(serviceFacade, never()).scheduleComponents(anyString(), any(), 
any());
+        verify(serviceFacade, never()).verifyScheduleComponents(anyString(), 
any(), any());
+    }
+
+    @Test
+    public void testStopSourcesWithNoSourcesReturnsEmptyComponents() {
+        final ScheduleComponentsEntity entity = new ScheduleComponentsEntity();
+        entity.setId(PROCESS_GROUP_ID);
+        entity.setState(ScheduledState.STOPPED.name());
+
+        when(properties.isNode()).thenReturn(false);
+        resource.httpServletRequest = new MockHttpServletRequest();
+
+        when(serviceFacade.getRevisionsFromGroup(eq(PROCESS_GROUP_ID), 
any())).thenReturn(Set.of());
+        when(serviceFacade.scheduleComponents(eq(PROCESS_GROUP_ID), 
eq(ScheduledState.STOPPED), any())).thenReturn(entity);
+
+        final Response response = resource.stopSources(PROCESS_GROUP_ID, 
entity);
+
+        assertEquals(HttpURLConnection.HTTP_OK, response.getStatus());
+        assertTrue(entity.getComponents().isEmpty());
+        verify(serviceFacade).scheduleComponents(eq(PROCESS_GROUP_ID), 
eq(ScheduledState.STOPPED), eq(Map.of()));
+    }
+
+    @Test
+    public void 
testStopSourcesStatelessProcessGroupDoesNotDiscoverOrSchedule() {
+        final RevisionDTO revision = new RevisionDTO();
+        revision.setVersion(1L);
+        final ScheduleComponentsEntity entity = new ScheduleComponentsEntity();
+        entity.setId(PROCESS_GROUP_ID);
+        entity.setState(ScheduledState.STOPPED.name());
+        entity.setComponents(Map.of("source-processor", revision));
+
+        doThrow(new IllegalStateException("Stateless Process 
Group")).when(serviceFacade).verifyStopSources(PROCESS_GROUP_ID);
+
+        assertThrows(IllegalStateException.class, () -> 
resource.stopSources(PROCESS_GROUP_ID, entity));
+
+        verify(serviceFacade, never()).getRevisionsFromGroup(anyString(), 
any());
+        verify(serviceFacade).authorizeAccess(any());
+        verify(serviceFacade, never()).verifyScheduleComponents(anyString(), 
any(), any());
+        verify(serviceFacade, never()).scheduleComponents(anyString(), any(), 
any());
+    }
+
+    @Test
+    public void testStopSourcesResponseIncludesStoppedComponentIds() {
+        final ScheduleComponentsEntity request = new 
ScheduleComponentsEntity();
+        request.setId(PROCESS_GROUP_ID);
+        request.setState(ScheduledState.STOPPED.name());
+
+        when(properties.isNode()).thenReturn(false);
+        resource.httpServletRequest = new MockHttpServletRequest();
+
+        when(serviceFacade.getRevisionsFromGroup(eq(PROCESS_GROUP_ID), any()))
+                .thenReturn(Set.of(new Revision(1L, "client", 
"source-processor")));
+
+        final ScheduleComponentsEntity facadeResponse = new 
ScheduleComponentsEntity();
+        facadeResponse.setId(PROCESS_GROUP_ID);
+        facadeResponse.setState(ScheduledState.STOPPED.name());
+        when(serviceFacade.scheduleComponents(eq(PROCESS_GROUP_ID), 
eq(ScheduledState.STOPPED), any())).thenReturn(facadeResponse);
+
+        final Response response = resource.stopSources(PROCESS_GROUP_ID, 
request);
+
+        assertEquals(HttpURLConnection.HTTP_OK, response.getStatus());
+        final ScheduleComponentsEntity responseEntity = 
(ScheduleComponentsEntity) response.getEntity();
+        assertNotNull(responseEntity.getComponents());
+        assertEquals(Set.of("source-processor"), 
responseEntity.getComponents().keySet());
+    }
+
+    @Test
+    public void testStopSourcesIdentifiesSourcesBeforeReplicate() {
+        final FlowResource spyResource = spy(resource);
+        doReturn(true).when(spyResource).isReplicateRequest();
+        
doReturn(Response.ok().build()).when(spyResource).replicate(anyString(), 
any(ScheduleComponentsEntity.class));
+
+        final ScheduleComponentsEntity request = new 
ScheduleComponentsEntity();
+        request.setId(PROCESS_GROUP_ID);
+        request.setState(ScheduledState.STOPPED.name());
+        when(serviceFacade.getRevisionsFromGroup(eq(PROCESS_GROUP_ID), 
any())).thenReturn(Set.of(
+                new Revision(1L, "client", "first-source"),
+                new Revision(2L, "client", "second-source")
+        ));
+
+        final Response response = spyResource.stopSources(PROCESS_GROUP_ID, 
request);
+
+        assertEquals(HttpURLConnection.HTTP_OK, response.getStatus());
+        assertEquals(Set.of("first-source", "second-source"), 
request.getComponents().keySet());
+        verify(serviceFacade).verifyStopSources(PROCESS_GROUP_ID, 
Set.of("first-source", "second-source"));
+        verify(serviceFacade, times(2)).authorizeAccess(any());
+        verify(spyResource).replicate(HttpMethod.PUT, request);
+        verify(serviceFacade, never()).scheduleComponents(anyString(), any(), 
any());
+    }
+
+    @Test
+    public void testStopSourcesRechecksIdentifiedSourcesBeforeScheduling() {
+        final RevisionDTO revisionDto = new RevisionDTO();
+        revisionDto.setClientId("client");
+        revisionDto.setVersion(1L);
+
+        final ScheduleComponentsEntity request = new 
ScheduleComponentsEntity();
+        request.setId(PROCESS_GROUP_ID);
+        request.setState(ScheduledState.STOPPED.name());
+        request.setComponents(Map.of("source-processor", revisionDto));
+        resource.httpServletRequest = new MockHttpServletRequest();
+
+        doNothing().when(serviceFacade).verifyStopSources(PROCESS_GROUP_ID);
+        doNothing()
+                .doThrow(new IllegalStateException("Source components 
changed"))
+                .when(serviceFacade).verifyStopSources(PROCESS_GROUP_ID, 
Set.of("source-processor"));
+
+        assertThrows(IllegalStateException.class, () -> 
resource.stopSources(PROCESS_GROUP_ID, request));
+
+        verify(serviceFacade, times(2)).verifyStopSources(PROCESS_GROUP_ID, 
Set.of("source-processor"));
+        verify(serviceFacade, never()).scheduleComponents(anyString(), any(), 
any());
+    }
+
+    @Test
+    public void testStopSourcesAuthorizesSuppliedComponentsBeforeReplicate() {
+        final FlowResource spyResource = spy(resource);
+        doReturn(true).when(spyResource).isReplicateRequest();
+        
doReturn(Response.ok().build()).when(spyResource).replicate(anyString(), 
any(ScheduleComponentsEntity.class));
+
+        final RevisionDTO firstRevision = new RevisionDTO();
+        firstRevision.setClientId("client");
+        firstRevision.setVersion(1L);
+        final RevisionDTO secondRevision = new RevisionDTO();
+        secondRevision.setClientId("client");
+        secondRevision.setVersion(2L);
+
+        final ScheduleComponentsEntity request = new 
ScheduleComponentsEntity();
+        request.setId(PROCESS_GROUP_ID);
+        request.setState(ScheduledState.STOPPED.name());
+        request.setComponents(Map.of("first-source", firstRevision, 
"second-source", secondRevision));
+
+        final Response response = spyResource.stopSources(PROCESS_GROUP_ID, 
request);
+
+        assertEquals(HttpURLConnection.HTTP_OK, response.getStatus());
+        final ArgumentCaptor<AuthorizeAccess> authorizeAccessCaptor = 
ArgumentCaptor.captor();
+        verify(serviceFacade, 
times(2)).authorizeAccess(authorizeAccessCaptor.capture());
+        final AuthorizableLookup lookup = mock(AuthorizableLookup.class);
+        final Authorizable flow = mock(Authorizable.class);
+        final Authorizable firstSource = mock(Authorizable.class);
+        final Authorizable secondSource = mock(Authorizable.class);
+        when(lookup.getFlow()).thenReturn(flow);
+        
when(lookup.getLocalConnectable("first-source")).thenReturn(firstSource);
+        
when(lookup.getLocalConnectable("second-source")).thenReturn(secondSource);
+
+        authorizeAccessCaptor.getAllValues().forEach(authorizeAccess -> 
authorizeAccess.authorize(lookup));
+
+        verify(flow).authorize(eq(authorizer), eq(RequestAction.READ), 
nullable(NiFiUser.class));
+        verify(firstSource).authorize(eq(authorizer), eq(RequestAction.WRITE), 
nullable(NiFiUser.class));
+        verify(secondSource).authorize(eq(authorizer), 
eq(RequestAction.WRITE), nullable(NiFiUser.class));
+        verify(serviceFacade).verifyStopSources(PROCESS_GROUP_ID, 
Set.of("first-source", "second-source"));
+        verify(serviceFacade, never()).getRevisionsFromGroup(anyString(), 
any());
+        verify(spyResource).replicate(eq(HttpMethod.PUT), eq(request));
+        verify(serviceFacade, never()).scheduleComponents(anyString(), any(), 
any());
+        assertEquals(Set.of("first-source", "second-source"), 
request.getComponents().keySet());
+    }
+
+    @Test
+    public void 
testStopSourcesUnauthorizedSuppliedComponentsDoesNotReplicate() {
+        final FlowResource spyResource = spy(resource);
+
+        final RevisionDTO revisionDto = new RevisionDTO();
+        revisionDto.setClientId("client");
+        revisionDto.setVersion(1L);
+
+        final ScheduleComponentsEntity request = new 
ScheduleComponentsEntity();
+        request.setId(PROCESS_GROUP_ID);
+        request.setState(ScheduledState.STOPPED.name());
+        request.setComponents(Map.of("source-processor", revisionDto));
+
+        final AuthorizableLookup lookup = mock(AuthorizableLookup.class);
+        final Authorizable flow = mock(Authorizable.class);
+        final Authorizable sourceProcessor = mock(Authorizable.class);
+        when(lookup.getFlow()).thenReturn(flow);
+        
when(lookup.getLocalConnectable("source-processor")).thenReturn(sourceProcessor);
+        doThrow(new AccessDeniedException("denied")).when(sourceProcessor)
+                .authorize(eq(authorizer), eq(RequestAction.WRITE), 
nullable(NiFiUser.class));
+        doAnswer(invocation -> {
+            invocation.getArgument(0, AuthorizeAccess.class).authorize(lookup);
+            return null;
+        }).when(serviceFacade).authorizeAccess(any());
+
+        assertThrows(AccessDeniedException.class, () -> 
spyResource.stopSources(PROCESS_GROUP_ID, request));
+
+        verify(serviceFacade, times(2)).authorizeAccess(any());
+        verify(flow).authorize(eq(authorizer), eq(RequestAction.READ), 
nullable(NiFiUser.class));
+        verify(sourceProcessor).authorize(eq(authorizer), 
eq(RequestAction.WRITE), nullable(NiFiUser.class));
+        verify(serviceFacade, never()).verifyStopSources(PROCESS_GROUP_ID, 
Set.of("source-processor"));
+        verify(spyResource, never()).replicate(anyString(), 
any(ScheduleComponentsEntity.class));
+        verify(serviceFacade, never()).scheduleComponents(anyString(), any(), 
any());
+        verify(serviceFacade, never()).getRevisionsFromGroup(anyString(), 
any());
+    }
+
     @Test
     public void testGetConnectors() {
         final ConnectorEntity connectorEntity = new ConnectorEntity();
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/DtoFactoryTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/DtoFactoryTest.java
index a6f4fb1cd81..5bd149c33f7 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/DtoFactoryTest.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/DtoFactoryTest.java
@@ -26,6 +26,7 @@ import org.apache.nifi.components.validation.ValidationStatus;
 import org.apache.nifi.connectable.Connectable;
 import org.apache.nifi.connectable.ConnectableType;
 import org.apache.nifi.connectable.Connection;
+import org.apache.nifi.connectable.Position;
 import org.apache.nifi.controller.ControllerService;
 import org.apache.nifi.controller.queue.FlowFileQueue;
 import org.apache.nifi.controller.queue.LoadBalanceCompression;
@@ -33,7 +34,13 @@ import org.apache.nifi.controller.queue.LoadBalanceStrategy;
 import org.apache.nifi.controller.service.ControllerServiceNode;
 import org.apache.nifi.controller.service.ControllerServiceProvider;
 import org.apache.nifi.controller.service.ControllerServiceState;
+import org.apache.nifi.controller.status.ProcessGroupStatus;
+import org.apache.nifi.flow.ExecutionEngine;
+import org.apache.nifi.groups.FlowFileConcurrency;
+import org.apache.nifi.groups.FlowFileOutboundPolicy;
 import org.apache.nifi.groups.ProcessGroup;
+import org.apache.nifi.groups.ProcessGroupCounts;
+import org.apache.nifi.groups.StatelessGroupScheduledState;
 import org.apache.nifi.logging.LogLevel;
 import org.apache.nifi.nar.ExtensionManager;
 import org.apache.nifi.nar.NarManifest;
@@ -51,6 +58,7 @@ import org.apache.nifi.registry.flow.FlowRegistryClientNode;
 import org.apache.nifi.registry.flow.diff.DifferenceType;
 import org.apache.nifi.registry.flow.diff.FlowDifference;
 import org.apache.nifi.web.ResourceNotFoundException;
+import org.apache.nifi.web.api.dto.flow.ProcessGroupFlowDTO;
 import org.apache.nifi.web.api.entity.AllowableValueEntity;
 import org.apache.nifi.web.api.entity.ParameterContextReferenceEntity;
 import org.apache.nifi.web.revision.RevisionManager;
@@ -1062,4 +1070,102 @@ public class DtoFactoryTest {
         
when(context.getParameterReferenceManager()).thenReturn(ParameterReferenceManager.EMPTY);
     }
 
+    @Test
+    void 
testCreateProcessGroupDtoSetsResolvedExecutionEngineForInheritedUnderStatelessParent()
 {
+        final ProcessGroup group = stubProcessGroup(ExecutionEngine.INHERITED, 
ExecutionEngine.STATELESS);
+
+        final ProcessGroupDTO dto = 
newDtoFactoryForParameters().createProcessGroupDto(group, false);
+
+        assertEquals("INHERITED", dto.getExecutionEngine());
+        assertEquals("STATELESS", dto.getResolvedExecutionEngine());
+    }
+
+    @Test
+    void 
testCreateProcessGroupDtoSetsResolvedExecutionEngineStandardForRootInherited() {
+        final ProcessGroup group = stubProcessGroup(ExecutionEngine.INHERITED, 
ExecutionEngine.STANDARD);
+
+        final ProcessGroupDTO dto = 
newDtoFactoryForParameters().createProcessGroupDto(group, false);
+
+        assertEquals("INHERITED", dto.getExecutionEngine());
+        assertEquals("STANDARD", dto.getResolvedExecutionEngine());
+    }
+
+    @Test
+    void 
testCreateProcessGroupDtoSetsResolvedExecutionEngineForConfiguredStateless() {
+        final ProcessGroup group = stubProcessGroup(ExecutionEngine.STATELESS, 
ExecutionEngine.STATELESS);
+
+        final ProcessGroupDTO dto = 
newDtoFactoryForParameters().createProcessGroupDto(group, false);
+
+        assertEquals("STATELESS", dto.getExecutionEngine());
+        assertEquals("STATELESS", dto.getResolvedExecutionEngine());
+    }
+
+    @Test
+    void testCreateProcessGroupFlowDtoSetsResolvedExecutionEngine() {
+        final ProcessGroup group = stubProcessGroup(ExecutionEngine.INHERITED, 
ExecutionEngine.STATELESS);
+        final ProcessGroupStatus groupStatus = mock(ProcessGroupStatus.class);
+        
when(groupStatus.getProcessorStatus()).thenReturn(Collections.emptyList());
+        
when(groupStatus.getConnectionStatus()).thenReturn(Collections.emptyList());
+        
when(groupStatus.getProcessGroupStatus()).thenReturn(Collections.emptyList());
+        
when(groupStatus.getRemoteProcessGroupStatus()).thenReturn(Collections.emptyList());
+        
when(groupStatus.getInputPortStatus()).thenReturn(Collections.emptyList());
+        
when(groupStatus.getOutputPortStatus()).thenReturn(Collections.emptyList());
+
+        final ProcessGroupFlowDTO dto = 
newDtoFactoryForParameters().createProcessGroupFlowDto(
+                group,
+                groupStatus,
+                mock(RevisionManager.class),
+                ignored -> Collections.emptyList(),
+                false
+        );
+
+        assertEquals("STATELESS", dto.getResolvedExecutionEngine());
+    }
+
+    @Test
+    void testCopyProcessGroupDtoCopiesResolvedExecutionEngine() {
+        final ProcessGroupDTO original = new ProcessGroupDTO();
+        original.setContents(new FlowSnippetDTO());
+        original.setExecutionEngine("INHERITED");
+        original.setResolvedExecutionEngine("STATELESS");
+
+        final ProcessGroupDTO copy = 
newDtoFactoryForParameters().copy(original, false);
+
+        assertEquals("INHERITED", copy.getExecutionEngine());
+        assertEquals("STATELESS", copy.getResolvedExecutionEngine());
+    }
+
+    private static ProcessGroup stubProcessGroup(final ExecutionEngine 
configured, final ExecutionEngine resolved) {
+        final ProcessGroup group = mock(ProcessGroup.class);
+        when(group.getIdentifier()).thenReturn("pg-1");
+        when(group.getPosition()).thenReturn(new Position(0, 0));
+        when(group.getComments()).thenReturn("");
+        when(group.getName()).thenReturn("group");
+        when(group.getVersionedComponentId()).thenReturn(Optional.empty());
+        when(group.getVersionControlInformation()).thenReturn(null);
+        
when(group.getFlowFileConcurrency()).thenReturn(FlowFileConcurrency.UNBOUNDED);
+        
when(group.getFlowFileOutboundPolicy()).thenReturn(FlowFileOutboundPolicy.STREAM_WHEN_AVAILABLE);
+        when(group.getDefaultFlowFileExpiration()).thenReturn("0 sec");
+        when(group.getDefaultBackPressureObjectThreshold()).thenReturn(10000L);
+        when(group.getDefaultBackPressureDataSizeThreshold()).thenReturn("1 
GB");
+        when(group.getLogFileSuffix()).thenReturn(null);
+        
when(group.getStatelessScheduledState()).thenReturn(StatelessGroupScheduledState.STOPPED);
+        when(group.getExecutionEngine()).thenReturn(configured);
+        when(group.resolveExecutionEngine()).thenReturn(resolved);
+        when(group.getMaxConcurrentTasks()).thenReturn(1);
+        when(group.getStatelessFlowTimeout()).thenReturn("1 min");
+        when(group.getParameterContext()).thenReturn(null);
+        when(group.getParent()).thenReturn(null);
+        when(group.getCounts()).thenReturn(new ProcessGroupCounts(0, 0, 0, 0, 
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0));
+        when(group.getProcessors()).thenReturn(Collections.emptySet());
+        when(group.getConnections()).thenReturn(Collections.emptySet());
+        when(group.getLabels()).thenReturn(Collections.emptySet());
+        when(group.getFunnels()).thenReturn(Collections.emptySet());
+        when(group.getProcessGroups()).thenReturn(Collections.emptySet());
+        
when(group.getRemoteProcessGroups()).thenReturn(Collections.emptySet());
+        when(group.getInputPorts()).thenReturn(Collections.emptySet());
+        when(group.getOutputPorts()).thenReturn(Collections.emptySet());
+        return group;
+    }
+
 }
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/EntityFactoryTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/EntityFactoryTest.java
new file mode 100644
index 00000000000..2edf44b2122
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/EntityFactoryTest.java
@@ -0,0 +1,58 @@
+/*
+ * 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.web.api.dto;
+
+import org.apache.nifi.web.api.entity.ProcessGroupEntity;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+public class EntityFactoryTest {
+
+    @Test
+    void 
testCreateProcessGroupEntityPromotesResolvedExecutionEngineWhenUnauthorized() {
+        final ProcessGroupDTO dto = new ProcessGroupDTO();
+        dto.setId("pg-1");
+        dto.setResolvedExecutionEngine("STATELESS");
+
+        final PermissionsDTO permissions = new PermissionsDTO();
+        permissions.setCanRead(false);
+        permissions.setCanWrite(false);
+
+        final ProcessGroupEntity entity = new 
EntityFactory().createProcessGroupEntity(dto, null, permissions, null, null);
+
+        assertEquals("STATELESS", entity.getResolvedExecutionEngine());
+        assertNull(entity.getComponent());
+    }
+
+    @Test
+    void testCreateProcessGroupEntityIncludesComponentWhenAuthorized() {
+        final ProcessGroupDTO dto = new ProcessGroupDTO();
+        dto.setId("pg-1");
+        dto.setResolvedExecutionEngine("STANDARD");
+
+        final PermissionsDTO permissions = new PermissionsDTO();
+        permissions.setCanRead(true);
+        permissions.setCanWrite(true);
+
+        final ProcessGroupEntity entity = new 
EntityFactory().createProcessGroupEntity(dto, null, permissions, null, null);
+
+        assertEquals("STANDARD", entity.getResolvedExecutionEngine());
+        assertEquals(dto, entity.getComponent());
+    }
+}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.spec.ts
new file mode 100644
index 00000000000..8d8f7966894
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.spec.ts
@@ -0,0 +1,175 @@
+/*
+ * 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.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import type { Mock } from 'vitest';
+
+import { CanvasContextMenu } from './canvas-context-menu.service';
+import { CanvasUtils } from './canvas-utils.service';
+import { Client } from '../../../service/client.service';
+import { CanvasView } from './canvas-view.service';
+import { CanvasActionsService } from './canvas-actions.service';
+import { DraggableBehavior } from './behavior/draggable-behavior.service';
+import * as FlowActions from '../state/flow/flow.actions';
+import type { ResolvedExecutionEngine } from '../state/flow';
+import type { ContextMenuItemDefinition } from 
'../../../ui/common/context-menu/context-menu.component';
+
+interface SetupOptions {
+    currentProcessGroupId?: string;
+    isProcessGroup?: boolean;
+    resolvedExecutionEngine?: ResolvedExecutionEngine;
+}
+
+function menuItem(menuItems: ContextMenuItemDefinition[], text: string): 
ContextMenuItemDefinition {
+    const item = menuItems.find((candidate) => candidate.text === text);
+    if (!item) {
+        throw new Error(`Expected menu item "${text}"`);
+    }
+    return item;
+}
+
+async function setup(options: SetupOptions = {}) {
+    const currentProcessGroupId = options.currentProcessGroupId ?? 
'current-pg';
+    const canvasUtils = {
+        getProcessGroupId: vi.fn().mockReturnValue(currentProcessGroupId),
+        isProcessGroup: vi.fn().mockReturnValue(options.isProcessGroup ?? 
false),
+        getResolvedExecutionEngine: 
vi.fn().mockReturnValue(options.resolvedExecutionEngine ?? 'STANDARD')
+    };
+
+    await TestBed.configureTestingModule({
+        providers: [
+            CanvasContextMenu,
+            provideMockStore(),
+            { provide: CanvasUtils, useValue: canvasUtils },
+            { provide: Client, useValue: {} },
+            { provide: CanvasView, useValue: {} },
+            {
+                provide: CanvasActionsService,
+                useValue: {
+                    getConditionFunction: () => () => false,
+                    getActionFunction: () => () => undefined
+                }
+            },
+            { provide: DraggableBehavior, useValue: {} }
+        ]
+    }).compileComponents();
+
+    const service = TestBed.inject(CanvasContextMenu);
+    const store = TestBed.inject(MockStore);
+    const dispatchSpy = vi.spyOn(store, 'dispatch') as Mock;
+
+    return { service, canvasUtils, dispatchSpy };
+}
+
+describe('CanvasContextMenu', () => {
+    describe('Stop Sources', () => {
+        it('is immediately after Stop in the root menu', async () => {
+            const { service } = await setup();
+            const menuItems = service.getMenu('root')!.menuItems;
+            const texts = menuItems.map((item) => item.text);
+            const stopIndex = texts.indexOf('Stop');
+            const stopSourcesIndex = texts.indexOf('Stop Sources');
+
+            expect(stopIndex).toBeGreaterThan(-1);
+            expect(stopSourcesIndex).toBe(stopIndex + 1);
+        });
+
+        it('is visible on an empty canvas and dispatches stopSources for the 
current group', async () => {
+            const { service, canvasUtils, dispatchSpy } = await setup({ 
currentProcessGroupId: 'current-pg' });
+            const stopSources = menuItem(service.getMenu('root')!.menuItems, 
'Stop Sources');
+            const selection = { empty: () => true };
+
+            expect(stopSources.condition!(selection as never)).toBe(true);
+            stopSources.action!(selection as never);
+
+            expect(canvasUtils.getProcessGroupId).toHaveBeenCalled();
+            expect(dispatchSpy).toHaveBeenCalledWith(FlowActions.stopSources({ 
request: { id: 'current-pg' } }));
+        });
+
+        it('is visible for a selected process group and dispatches stopSources 
for that group', async () => {
+            const { service, dispatchSpy } = await setup({ isProcessGroup: 
true });
+            const stopSources = menuItem(service.getMenu('root')!.menuItems, 
'Stop Sources');
+            const selection = {
+                empty: () => false,
+                datum: () => ({ id: 'pg-child', resolvedExecutionEngine: 
'STANDARD' })
+            };
+
+            expect(stopSources.condition!(selection as never)).toBe(true);
+            stopSources.action!(selection as never);
+
+            expect(dispatchSpy).toHaveBeenCalledWith(FlowActions.stopSources({ 
request: { id: 'pg-child' } }));
+        });
+
+        it('is hidden when the selection is not a process group', async () => {
+            const { service } = await setup({ isProcessGroup: false });
+            const stopSources = menuItem(service.getMenu('root')!.menuItems, 
'Stop Sources');
+            const selection = { empty: () => false };
+
+            expect(stopSources.condition!(selection as never)).toBe(false);
+        });
+
+        it('is hidden on an empty canvas when the current group resolves to 
STATELESS', async () => {
+            const { service } = await setup({ resolvedExecutionEngine: 
'STATELESS' });
+            const stopSources = menuItem(service.getMenu('root')!.menuItems, 
'Stop Sources');
+            const selection = { empty: () => true };
+
+            expect(stopSources.condition!(selection as never)).toBe(false);
+        });
+
+        it('is hidden for a selected process group when the resolved engine is 
missing', async () => {
+            const { service } = await setup({ isProcessGroup: true });
+            const stopSources = menuItem(service.getMenu('root')!.menuItems, 
'Stop Sources');
+            const selection = {
+                empty: () => false,
+                datum: () => ({ id: 'pg-child' })
+            };
+
+            expect(stopSources.condition!(selection as never)).toBe(false);
+        });
+
+        it('is visible on an empty canvas when the current group is configured 
INHERITED but resolves to STANDARD', async () => {
+            const { service } = await setup({ resolvedExecutionEngine: 
'STANDARD' });
+            const stopSources = menuItem(service.getMenu('root')!.menuItems, 
'Stop Sources');
+            const selection = { empty: () => true };
+
+            expect(stopSources.condition!(selection as never)).toBe(true);
+        });
+
+        it('is visible for a selected process group with no component when the 
entity resolves to STANDARD', async () => {
+            const { service } = await setup({ isProcessGroup: true });
+            const stopSources = menuItem(service.getMenu('root')!.menuItems, 
'Stop Sources');
+            const selection = {
+                empty: () => false,
+                datum: () => ({ id: 'pg-child', resolvedExecutionEngine: 
'STANDARD' })
+            };
+
+            expect(stopSources.condition!(selection as never)).toBe(true);
+        });
+
+        it('is hidden for a selected process group with no component when the 
entity resolves to STATELESS', async () => {
+            const { service } = await setup({ isProcessGroup: true });
+            const stopSources = menuItem(service.getMenu('root')!.menuItems, 
'Stop Sources');
+            const selection = {
+                empty: () => false,
+                datum: () => ({ id: 'pg-child', resolvedExecutionEngine: 
'STATELESS' })
+            };
+
+            expect(stopSources.condition!(selection as never)).toBe(false);
+        });
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts
index 5eb22074894..6267e71468f 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts
@@ -47,6 +47,7 @@ import {
     replayLastProvenanceEvent,
     requestRefreshRemoteProcessGroup,
     runOnce,
+    stopSources,
     stopVersionControlRequest,
     terminateThreads,
     updatePositions
@@ -773,6 +774,36 @@ export class CanvasContextMenu implements 
ContextMenuDefinitionProvider {
                 text: 'Stop',
                 action: this.canvasActionsService.getActionFunction('stop')
             },
+            {
+                condition: (selection: any) => {
+                    if (!(selection.empty() || 
this.canvasUtils.isProcessGroup(selection))) {
+                        return false;
+                    }
+                    const resolved = selection.empty()
+                        ? this.canvasUtils.getResolvedExecutionEngine()
+                        : selection.datum().resolvedExecutionEngine;
+                    return resolved === 'STANDARD';
+                },
+                clazz: 'fa fa-stop-circle-o',
+                text: 'Stop Sources',
+                action: (selection: any) => {
+                    let processGroupId: string;
+                    if (selection.empty()) {
+                        processGroupId = this.canvasUtils.getProcessGroupId();
+                    } else {
+                        const selectionData = selection.datum();
+                        processGroupId = selectionData.id;
+                    }
+
+                    this.store.dispatch(
+                        stopSources({
+                            request: {
+                                id: processGroupId
+                            }
+                        })
+                    );
+                }
+            },
             {
                 condition: (selection: any) => {
                     if (selection.size() !== 1) {
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
index b8847fc7271..8da73babd8e 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
@@ -20,13 +20,15 @@ import * as d3 from 'd3';
 import { humanizer, Humanizer } from 'humanize-duration';
 import { Store } from '@ngrx/store';
 import { CanvasState } from '../state';
+import type { ResolvedExecutionEngine } from '../state/flow';
 import {
     selectBreadcrumbs,
     selectCanvasPermissions,
     selectConnections,
     selectCurrentParameterContext,
     selectCurrentProcessGroupId,
-    selectParentProcessGroupId
+    selectParentProcessGroupId,
+    selectResolvedExecutionEngine
 } from '../state/flow/flow.selectors';
 import { initialState as initialFlowState } from '../state/flow/flow.reducer';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -80,6 +82,8 @@ export class CanvasUtils {
     private trimLengthCaches: Map<string, Map<string, Map<number, number>>> = 
new Map();
     private currentProcessGroupId: string = initialFlowState.id;
     private parentProcessGroupId: string | null = 
initialFlowState.flow.processGroupFlow.parentGroupId;
+    private currentResolvedExecutionEngine: ResolvedExecutionEngine =
+        initialFlowState.flow.processGroupFlow.resolvedExecutionEngine;
     private canvasPermissions: Permissions = initialFlowState.flow.permissions;
     private currentUser: CurrentUser = initialUserState.user;
     private currentParameterContext: ParameterContextReferenceEntity | null =
@@ -110,6 +114,13 @@ export class CanvasUtils {
                 this.parentProcessGroupId = parentProcessGroupId;
             });
 
+        this.store
+            .select(selectResolvedExecutionEngine)
+            .pipe(takeUntilDestroyed(this.destroyRef))
+            .subscribe((resolvedExecutionEngine) => {
+                this.currentResolvedExecutionEngine = resolvedExecutionEngine;
+            });
+
         this.store
             .select(selectCanvasPermissions)
             .pipe(takeUntilDestroyed(this.destroyRef))
@@ -229,6 +240,14 @@ export class CanvasUtils {
         return this.currentProcessGroupId;
     }
 
+    /**
+     * The Execution Engine that will actually run the current Process Group
+     * after resolving INHERITED. Always STANDARD or STATELESS.
+     */
+    public getResolvedExecutionEngine(): ResolvedExecutionEngine {
+        return this.currentResolvedExecutionEngine;
+    }
+
     /**
      * Returns the parent group id or null if current is root.
      */
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts
index b605afeda3f..1f5f469ab30 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts
@@ -43,6 +43,8 @@ import {
     SaveToVersionControlRequest,
     StartProcessGroupRequest,
     StopProcessGroupRequest,
+    StopSourcesRequest,
+    StopSourcesResponse,
     StopVersionControlRequest,
     TerminateThreadsRequest,
     UploadProcessGroupRequest,
@@ -403,6 +405,18 @@ export class FlowService implements 
PropertyDescriptorRetriever {
         return 
this.httpClient.put(`${FlowService.API}/flow/process-groups/${request.id}`, 
stopRequest);
     }
 
+    stopSources(request: StopSourcesRequest): Observable<StopSourcesResponse> {
+        const stopRequest: ProcessGroupRunStatusRequest = {
+            id: request.id,
+            disconnectedNodeAcknowledged: 
this.clusterConnectionService.isDisconnectionAcknowledged(),
+            state: 'STOPPED'
+        };
+        return this.httpClient.put<StopSourcesResponse>(
+            `${FlowService.API}/flow/process-groups/${request.id}/sources`,
+            stopRequest
+        );
+    }
+
     stopRemoteProcessGroupsInProcessGroup(request: StopProcessGroupRequest): 
Observable<any> {
         const stopRequest = {
             disconnectedNodeAcknowledged: 
this.clusterConnectionService.isDisconnectionAcknowledged(),
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
index 76587c6a0cc..441f5b5c5e8 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
@@ -92,6 +92,7 @@ import {
     StopComponentsRequest,
     StopProcessGroupRequest,
     StopProcessGroupResponse,
+    StopSourcesRequest,
     StopVersionControlRequest,
     StopVersionControlResponse,
     TerminateThreadsRequest,
@@ -749,6 +750,13 @@ export const startCurrentProcessGroup = 
createAction(`${CANVAS_PREFIX} Start Cur
 
 export const stopCurrentProcessGroup = createAction(`${CANVAS_PREFIX} Stop 
Current Process Group`);
 
+export const stopSources = createAction(`${CANVAS_PREFIX} Stop Sources`, 
props<{ request: StopSourcesRequest }>());
+
+export const stopSourcesSuccess = createAction(
+    `${CANVAS_PREFIX} Stop Sources Success`,
+    props<{ response: StopProcessGroupResponse }>()
+);
+
 export const enableControllerServicesInCurrentProcessGroup = createAction(
     `${CANVAS_PREFIX} Enable Controller Services In Current Process Group`
 );
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
index f8ffd720ac8..4dc36a34dd7 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
@@ -17,7 +17,7 @@
 
 import { FlowService } from '../../service/flow.service';
 import * as FlowActions from './flow.actions';
-import { of, ReplaySubject, take, throwError } from 'rxjs';
+import { firstValueFrom, of, ReplaySubject, Subject, take, throwError, toArray 
} from 'rxjs';
 import { MatDialog, MatDialogRef } from '@angular/material/dialog';
 import { ComponentHistoryEntity } from '../../../../state/shared';
 import { EditProcessor } from 
'../../../../ui/common/component-dialogs/edit-processor/edit-processor.component';
@@ -35,7 +35,8 @@ import {
     CreateComponentResponse,
     CreateConnection,
     flowFeatureKey,
-    MoveToFrontRequest
+    MoveToFrontRequest,
+    StopSourcesResponse
 } from './index';
 import {
     BacklogRequestEntity,
@@ -81,7 +82,6 @@ import * as fromParameter from 
'../parameter/parameter.reducer';
 import { flowAnalysisFeatureKey } from '../flow-analysis';
 import * as fromFlowAnalysis from '../flow-analysis/flow-analysis.reducer';
 import * as EmptyQueueActions from 
'../../../../state/empty-queue/empty-queue.actions';
-import { firstValueFrom } from 'rxjs';
 
 describe('FlowEffects', () => {
     let action$: ReplaySubject<Action>;
@@ -834,7 +834,8 @@ describe('FlowEffects', () => {
                         createConnection: vi.fn(),
                         createLabel: vi.fn(),
                         clearBulletinsForProcessGroup: vi.fn(),
-                        submitProcessorBacklogRequest: vi.fn()
+                        submitProcessorBacklogRequest: vi.fn(),
+                        stopSources: vi.fn()
                     }
                 },
                 {
@@ -1127,6 +1128,139 @@ describe('FlowEffects', () => {
         });
     });
 
+    describe('stopSources$', () => {
+        beforeEach(() => {
+            effects = TestBed.inject(FlowEffects);
+        });
+
+        it('should call flowService.stopSources and dispatch 
stopSourcesSuccess', async () => {
+            vi.spyOn(flowService, 'stopSources').mockReturnValue(
+                of({ id: 'test-group-id', state: 'STOPPED', components: {} })
+            );
+
+            action$.next(FlowActions.stopSources({ request: { id: 
'test-group-id' } }));
+
+            const result = await new Promise((resolve) => 
effects.stopSources$.pipe(take(1)).subscribe(resolve));
+
+            expect(result).toEqual(
+                FlowActions.stopSourcesSuccess({
+                    response: {
+                        type: ComponentType.ProcessGroup,
+                        component: { id: 'test-group-id', state: 'STOPPED' }
+                    }
+                })
+            );
+            expect(flowService.stopSources).toHaveBeenCalledWith({ id: 
'test-group-id' });
+        });
+
+        it('should keep overlapping stop sources requests active', async () => 
{
+            const firstResponse$ = new Subject<StopSourcesResponse>();
+            const secondResponse$ = new Subject<StopSourcesResponse>();
+            vi.spyOn(flowService, 'stopSources').mockImplementation((request) 
=>
+                request.id === 'first-group-id' ? firstResponse$ : 
secondResponse$
+            );
+            const resultsPromise = 
firstValueFrom(effects.stopSources$.pipe(take(2), toArray()));
+
+            action$.next(FlowActions.stopSources({ request: { id: 
'first-group-id' } }));
+            action$.next(FlowActions.stopSources({ request: { id: 
'second-group-id' } }));
+            secondResponse$.next({ id: 'second-group-id', state: 'STOPPED', 
components: {} });
+            secondResponse$.complete();
+            firstResponse$.next({ id: 'first-group-id', state: 'STOPPED', 
components: {} });
+            firstResponse$.complete();
+
+            expect(await resultsPromise).toEqual([
+                FlowActions.stopSourcesSuccess({
+                    response: {
+                        type: ComponentType.ProcessGroup,
+                        component: { id: 'second-group-id', state: 'STOPPED' }
+                    }
+                }),
+                FlowActions.stopSourcesSuccess({
+                    response: {
+                        type: ComponentType.ProcessGroup,
+                        component: { id: 'first-group-id', state: 'STOPPED' }
+                    }
+                })
+            ]);
+            expect(flowService.stopSources).toHaveBeenCalledTimes(2);
+        });
+
+        it('should dispatch flowSnackbarError and not stopSourcesSuccess when 
stopSources fails', async () => {
+            const errorHelper = TestBed.inject(ErrorHelper);
+            const errorResponse = new HttpErrorResponse({
+                error: 'stop sources failed',
+                status: 409,
+                statusText: 'Conflict'
+            });
+            vi.spyOn(flowService, 'stopSources').mockReturnValue(throwError(() 
=> errorResponse));
+            vi.spyOn(errorHelper, 'getErrorString').mockReturnValue('Formatted 
error message');
+
+            action$.next(FlowActions.stopSources({ request: { id: 
'test-group-id' } }));
+
+            const result = await new Promise((resolve) => 
effects.stopSources$.pipe(take(1)).subscribe(resolve));
+
+            expect(result).toEqual(FlowActions.flowSnackbarError({ error: 
'Formatted error message' }));
+            expect((result as 
Action).type).not.toBe(FlowActions.stopSourcesSuccess.type);
+            expect(flowService.stopSources).toHaveBeenCalledWith({ id: 
'test-group-id' });
+        });
+    });
+
+    describe('stopSourcesCurrentProcessGroupSuccess$', () => {
+        beforeEach(() => {
+            effects = TestBed.inject(FlowEffects);
+        });
+
+        it('should dispatch reloadFlow when sources are stopped in the current 
process group', async () => {
+            store.overrideSelector(selectCurrentProcessGroupId, 
'test-group-id');
+            store.refreshState();
+
+            action$.next(
+                FlowActions.stopSourcesSuccess({
+                    response: {
+                        type: ComponentType.ProcessGroup,
+                        component: { id: 'test-group-id', state: 'STOPPED' }
+                    }
+                })
+            );
+
+            const result = await new Promise((resolve) =>
+                
effects.stopSourcesCurrentProcessGroupSuccess$.pipe(take(1)).subscribe(resolve)
+            );
+
+            expect(result).toEqual(FlowActions.reloadFlow());
+        });
+    });
+
+    describe('stopSourcesSuccess$', () => {
+        beforeEach(() => {
+            effects = TestBed.inject(FlowEffects);
+        });
+
+        it('should dispatch loadChildProcessGroup when sources are stopped in 
a child process group', async () => {
+            store.overrideSelector(selectCurrentProcessGroupId, 'current-pg');
+            store.refreshState();
+
+            action$.next(
+                FlowActions.stopSourcesSuccess({
+                    response: {
+                        type: ComponentType.ProcessGroup,
+                        component: { id: 'pg-child', state: 'STOPPED' }
+                    }
+                })
+            );
+
+            const result = await new Promise((resolve) => 
effects.stopSourcesSuccess$.pipe(take(1)).subscribe(resolve));
+
+            expect(result).toEqual(
+                FlowActions.loadChildProcessGroup({
+                    request: {
+                        id: 'pg-child'
+                    }
+                })
+            );
+        });
+    });
+
     describe('clearBulletinsForProcessGroup$', () => {
         beforeEach(() => {
             effects = TestBed.inject(FlowEffects);
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
index 5413002cecf..5d9220f4699 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
@@ -3656,6 +3656,64 @@ export class FlowEffects {
         )
     );
 
+    stopSources$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(FlowActions.stopSources),
+            map((action) => action.request),
+            mergeMap((request) =>
+                from(this.flowService.stopSources(request)).pipe(
+                    map((response) =>
+                        FlowActions.stopSourcesSuccess({
+                            response: {
+                                type: ComponentType.ProcessGroup,
+                                component: {
+                                    id: response.id,
+                                    state: response.state
+                                }
+                            }
+                        })
+                    ),
+                    catchError((errorResponse: HttpErrorResponse) => 
of(this.snackBarOrFullScreenError(errorResponse)))
+                )
+            )
+        )
+    );
+
+    /**
+     * If sources were stopped in the current process group, reload the flow
+     */
+    stopSourcesCurrentProcessGroupSuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(FlowActions.stopSourcesSuccess),
+            map((action) => action.response),
+            concatLatestFrom(() => 
this.store.select(selectCurrentProcessGroupId)),
+            filter(([response, currentPg]) => response.component.id === 
currentPg),
+            switchMap(() => of(FlowActions.reloadFlow()))
+        )
+    );
+
+    /**
+     * If sources were stopped in a child ProcessGroup, reload that row; the
+     * schedule response does not contain all the displayed info
+     */
+    stopSourcesSuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(FlowActions.stopSourcesSuccess),
+            map((action) => action.response),
+            concatLatestFrom(() => 
this.store.select(selectCurrentProcessGroupId)),
+            filter(([response, currentPg]) => response.component.id !== 
currentPg),
+            switchMap(([response]) =>
+                of(
+                    FlowActions.loadChildProcessGroup({
+                        request: {
+                            id: response.component.id
+                        }
+                    })
+                )
+            )
+        )
+    );
+
     enableControllerServicesInCurrentProcessGroup$ = createEffect(() =>
         this.actions$.pipe(
             ofType(FlowActions.enableControllerServicesInCurrentProcessGroup),
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts
index f9d0122704c..6e6f602ef42 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts
@@ -73,6 +73,8 @@ import {
     startComponentSuccess,
     startPollingProcessorUntilStopped,
     startProcessGroupSuccess,
+    stopSources,
+    stopSourcesSuccess,
     startRemoteProcessGroupPolling,
     stopComponent,
     stopComponentSuccess,
@@ -123,6 +125,7 @@ export const initialState: FlowState = {
                 }
             },
             parameterContext: null,
+            resolvedExecutionEngine: 'STANDARD',
             flow: {
                 processGroups: [],
                 remoteProcessGroups: [],
@@ -432,6 +435,7 @@ export const flowReducer = createReducer(
         disableComponent,
         startComponent,
         stopComponent,
+        stopSources,
         runOnce,
         (state) => ({
             ...state,
@@ -443,6 +447,7 @@ export const flowReducer = createReducer(
         disableProcessGroupSuccess,
         startProcessGroupSuccess,
         stopProcessGroupSuccess,
+        stopSourcesSuccess,
         (state) => ({
             ...state,
             saving: false
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
index 27b84bddbb0..a699a24460a 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
@@ -70,6 +70,11 @@ export const selectParentProcessGroupId = createSelector(
     (state: FlowState) => state.flow.processGroupFlow.parentGroupId
 );
 
+export const selectResolvedExecutionEngine = createSelector(
+    selectFlowState,
+    (state: FlowState) => state.flow.processGroupFlow.resolvedExecutionEngine
+);
+
 export const selectProcessGroupIdFromRoute = 
createSelector(selectCurrentRoute, (route) => {
     if (route) {
         // always select the process group from the route
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
index c4e821d0152..1b7b830ec22 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
@@ -470,6 +470,8 @@ export interface Flow {
     funnels: ComponentEntity[];
 }
 
+export type ResolvedExecutionEngine = 'STANDARD' | 'STATELESS';
+
 export interface ProcessGroupFlow {
     id: string;
     uri: string;
@@ -478,6 +480,7 @@ export interface ProcessGroupFlow {
     parameterContext: ParameterContextReferenceEntity | null;
     flow: Flow;
     lastRefreshed: string;
+    resolvedExecutionEngine: ResolvedExecutionEngine;
 }
 
 export interface ProcessGroupFlowEntity {
@@ -648,6 +651,17 @@ export interface StopProcessGroupRequest {
     errorStrategy: 'snackbar' | 'banner';
 }
 
+export interface StopSourcesRequest {
+    id: string;
+}
+
+export interface StopSourcesResponse {
+    id: string;
+    state: 'STOPPED';
+    components: Record<string, Revision>;
+    disconnectedNodeAcknowledged?: boolean;
+}
+
 export interface StopComponentResponse {
     type: ComponentType;
     component: ComponentEntity;
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/pg/ClusteredStopSourcesIT.java
 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/pg/ClusteredStopSourcesIT.java
new file mode 100644
index 00000000000..3b5b2c9e1ec
--- /dev/null
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/pg/ClusteredStopSourcesIT.java
@@ -0,0 +1,260 @@
+/*
+ * 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 jakarta.ws.rs.WebApplicationException;
+import org.apache.nifi.controller.ScheduledState;
+import org.apache.nifi.groups.StatelessGroupScheduledState;
+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.entity.ProcessGroupEntity;
+import org.apache.nifi.web.api.entity.ProcessorEntity;
+import org.apache.nifi.web.api.entity.ScheduleComponentsEntity;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.fail;
+
+public class ClusteredStopSourcesIT extends NiFiSystemIT {
+
+    @Override
+    public NiFiInstanceFactory getInstanceFactory() {
+        return createTwoNodeInstanceFactory();
+    }
+
+    @Test
+    public void testStopSourcesSkipsStatelessDescendants() throws 
NiFiClientException, IOException, InterruptedException {
+        final MixedEngineFlow flow = createMixedEngineFlow();
+        startMixedEngineFlow(flow);
+
+        final ScheduleComponentsEntity response = 
getNifiClient().getFlowClient()
+                .stopProcessGroupSources(flow.parentGroup().getId(), 
stopSourcesRequest(flow.parentGroup().getId()));
+
+        assertEquals(Set.of(flow.standardSource().getId()), 
response.getComponents().keySet());
+        assertProcessorStateOnAllNodes(flow.standardSource().getId(), 
ScheduledState.STOPPED);
+        assertProcessorStateOnAllNodes(flow.standardDownstream().getId(), 
ScheduledState.RUNNING);
+        assertProcessorStateOnAllNodes(flow.statelessSource().getId(), 
ScheduledState.RUNNING);
+        assertProcessorStateOnAllNodes(flow.statelessDownstream().getId(), 
ScheduledState.RUNNING);
+        assertStatelessGroupStateOnAllNodes(flow.statelessGroup().getId(), 
StatelessGroupScheduledState.RUNNING);
+    }
+
+    @Test
+    public void testStopSourcesRejectsStatelessProcessGroup() throws 
NiFiClientException, IOException, InterruptedException {
+        final MixedEngineFlow flow = createMixedEngineFlow();
+        startMixedEngineFlow(flow);
+
+        final NiFiClientException exception = 
assertThrows(NiFiClientException.class, () -> getNifiClient().getFlowClient()
+                .stopProcessGroupSources(flow.statelessGroup().getId(), 
stopSourcesRequest(flow.statelessGroup().getId())));
+
+        assertConflict(exception);
+        assertProcessorStateOnAllNodes(flow.statelessSource().getId(), 
ScheduledState.RUNNING);
+        assertProcessorStateOnAllNodes(flow.statelessDownstream().getId(), 
ScheduledState.RUNNING);
+        assertStatelessGroupStateOnAllNodes(flow.statelessGroup().getId(), 
StatelessGroupScheduledState.RUNNING);
+    }
+
+    @Test
+    public void testStopSourcesRejectsMismatchedComponentIds() throws 
NiFiClientException, IOException, InterruptedException {
+        final MixedEngineFlow flow = createMixedEngineFlow();
+        startMixedEngineFlow(flow);
+        final ScheduleComponentsEntity request = 
stopSourcesRequest(flow.parentGroup().getId());
+        request.setComponents(Map.of());
+
+        final NiFiClientException exception = 
assertThrows(NiFiClientException.class, () -> getNifiClient().getFlowClient()
+                .stopProcessGroupSources(flow.parentGroup().getId(), request));
+
+        assertConflict(exception);
+        assertProcessorStateOnAllNodes(flow.standardSource().getId(), 
ScheduledState.RUNNING);
+        assertProcessorStateOnAllNodes(flow.standardDownstream().getId(), 
ScheduledState.RUNNING);
+        assertProcessorStateOnAllNodes(flow.statelessSource().getId(), 
ScheduledState.RUNNING);
+        assertProcessorStateOnAllNodes(flow.statelessDownstream().getId(), 
ScheduledState.RUNNING);
+        assertStatelessGroupStateOnAllNodes(flow.statelessGroup().getId(), 
StatelessGroupScheduledState.RUNNING);
+    }
+
+    @Test
+    public void testStopSourcesRejectsWhenNodeIdentifiesDifferentSources() 
throws NiFiClientException, IOException, InterruptedException {
+        final MixedEngineFlow flow = createMixedEngineFlow();
+        startMixedEngineFlow(flow);
+        final long node1Revision = 
stopAndRestartProcessorOnNode(flow.standardSource().getId(), 1);
+        final long node2Revision = 
stopAndRenameProcessorOnNode(flow.standardSource().getId(), 2);
+
+        assertEquals(node1Revision, node2Revision, "Processor revisions must 
match so source-set verification rejects the request");
+        assertProcessorStateOnNode(flow.standardSource().getId(), 
ScheduledState.RUNNING, 1);
+        assertProcessorStateOnNode(flow.standardSource().getId(), 
ScheduledState.STOPPED, 2);
+
+        final NiFiClientException exception = 
assertThrows(NiFiClientException.class, () -> getNifiClient().getFlowClient()
+                .stopProcessGroupSources(flow.parentGroup().getId(), 
stopSourcesRequest(flow.parentGroup().getId())));
+
+        assertConflict(exception);
+        assertProcessorStateOnNode(flow.standardSource().getId(), 
ScheduledState.RUNNING, 1);
+        assertProcessorStateOnNode(flow.standardSource().getId(), 
ScheduledState.STOPPED, 2);
+        assertProcessorStateOnAllNodes(flow.standardDownstream().getId(), 
ScheduledState.RUNNING);
+        assertProcessorStateOnAllNodes(flow.statelessSource().getId(), 
ScheduledState.RUNNING);
+        assertProcessorStateOnAllNodes(flow.statelessDownstream().getId(), 
ScheduledState.RUNNING);
+        assertStatelessGroupStateOnAllNodes(flow.statelessGroup().getId(), 
StatelessGroupScheduledState.RUNNING);
+    }
+
+    private MixedEngineFlow createMixedEngineFlow() throws 
NiFiClientException, IOException, InterruptedException {
+        final ProcessGroupEntity parentGroup = 
getClientUtil().createProcessGroup("Parent", "root");
+        final ProcessGroupEntity standardGroup = 
getClientUtil().createProcessGroup("Standard", parentGroup.getId());
+        final ProcessGroupEntity statelessGroup = 
getClientUtil().createProcessGroup("Stateless", parentGroup.getId());
+        getClientUtil().markStateless(statelessGroup, "1 min");
+
+        final ProcessorEntity standardSource = 
getClientUtil().createProcessor(GENERATE_FLOWFILE, standardGroup.getId());
+        final ProcessorEntity standardDownstream = 
getClientUtil().createProcessor(TERMINATE_FLOWFILE, standardGroup.getId());
+        getClientUtil().createConnection(standardSource, standardDownstream, 
SUCCESS, standardGroup.getId());
+
+        final ProcessorEntity statelessSource = 
getClientUtil().createProcessor(GENERATE_FLOWFILE, statelessGroup.getId());
+        final ProcessorEntity statelessDownstream = 
getClientUtil().createProcessor(TERMINATE_FLOWFILE, statelessGroup.getId());
+        getClientUtil().createConnection(statelessSource, statelessDownstream, 
SUCCESS, statelessGroup.getId());
+
+        getClientUtil().waitForValidProcessor(standardSource.getId());
+        getClientUtil().waitForValidProcessor(standardDownstream.getId());
+        getClientUtil().waitForValidProcessor(statelessSource.getId());
+        getClientUtil().waitForValidProcessor(statelessDownstream.getId());
+
+        return new MixedEngineFlow(parentGroup, standardGroup, statelessGroup,
+                standardSource, standardDownstream, statelessSource, 
statelessDownstream);
+    }
+
+    private void startMixedEngineFlow(final MixedEngineFlow flow) throws 
NiFiClientException, IOException, InterruptedException {
+        
getClientUtil().startProcessGroupComponents(flow.standardGroup().getId());
+        
getClientUtil().startProcessGroupComponents(flow.statelessGroup().getId());
+        getClientUtil().waitForRunningProcessor(flow.standardSource().getId());
+        
getClientUtil().waitForRunningProcessor(flow.standardDownstream().getId());
+        
getClientUtil().waitForRunningProcessor(flow.statelessSource().getId());
+        
getClientUtil().waitForRunningProcessor(flow.statelessDownstream().getId());
+    }
+
+    private ScheduleComponentsEntity stopSourcesRequest(final String groupId) {
+        final ScheduleComponentsEntity request = new 
ScheduleComponentsEntity();
+        request.setId(groupId);
+        request.setState(ScheduledState.STOPPED.name());
+        request.setDisconnectedNodeAcknowledged(true);
+        return request;
+    }
+
+    private long stopAndRestartProcessorOnNode(final String processorId, final 
int nodeIndex)
+            throws NiFiClientException, IOException, InterruptedException {
+        try {
+            switchClientToNode(nodeIndex);
+            final ProcessorEntity processor = 
getNifiClient().getProcessorClient(DO_NOT_REPLICATE).getProcessor(processorId);
+            processor.setDisconnectedNodeAcknowledged(true);
+            
getNifiClient().getProcessorClient(DO_NOT_REPLICATE).stopProcessor(processor);
+            waitForProcessorStateOnCurrentNode(processorId, 
ScheduledState.STOPPED);
+
+            final ProcessorEntity stoppedProcessor = 
getNifiClient().getProcessorClient(DO_NOT_REPLICATE).getProcessor(processorId);
+            stoppedProcessor.setDisconnectedNodeAcknowledged(true);
+            final ProcessorEntity restartedProcessor = 
getNifiClient().getProcessorClient(DO_NOT_REPLICATE).startProcessor(stoppedProcessor);
+            return restartedProcessor.getRevision().getVersion();
+        } finally {
+            switchClientToNode(1);
+        }
+    }
+
+    private long stopAndRenameProcessorOnNode(final String processorId, final 
int nodeIndex)
+            throws NiFiClientException, IOException, InterruptedException {
+        try {
+            switchClientToNode(nodeIndex);
+            final ProcessorEntity processor = 
getNifiClient().getProcessorClient(DO_NOT_REPLICATE).getProcessor(processorId);
+            processor.setDisconnectedNodeAcknowledged(true);
+            
getNifiClient().getProcessorClient(DO_NOT_REPLICATE).stopProcessor(processor);
+            waitForProcessorStateOnCurrentNode(processorId, 
ScheduledState.STOPPED);
+
+            final ProcessorEntity stoppedProcessor = 
getNifiClient().getProcessorClient(DO_NOT_REPLICATE).getProcessor(processorId);
+            
stoppedProcessor.getComponent().setName(stoppedProcessor.getComponent().getName()
 + " Node " + nodeIndex);
+            stoppedProcessor.setDisconnectedNodeAcknowledged(true);
+            final ProcessorEntity renamedProcessor = 
getNifiClient().getProcessorClient(DO_NOT_REPLICATE).updateProcessor(stoppedProcessor);
+            return renamedProcessor.getRevision().getVersion();
+        } finally {
+            switchClientToNode(1);
+        }
+    }
+
+    private void waitForProcessorStateOnCurrentNode(final String processorId, 
final ScheduledState expectedState) throws InterruptedException {
+        waitFor(() -> {
+            final ProcessorEntity processor = 
getNifiClient().getProcessorClient(DO_NOT_REPLICATE).getProcessor(processorId);
+            return 
expectedState.name().equals(processor.getComponent().getState())
+                    && 
expectedState.name().equals(processor.getComponent().getPhysicalState());
+        });
+    }
+
+    private void assertProcessorStateOnNode(final String processorId, final 
ScheduledState expectedState, final int nodeIndex)
+            throws NiFiClientException, IOException {
+        try {
+            switchClientToNode(nodeIndex);
+            final ProcessorEntity processor = 
getNifiClient().getProcessorClient(DO_NOT_REPLICATE).getProcessor(processorId);
+            assertEquals(expectedState.name(), 
processor.getComponent().getState(),
+                    "Unexpected state for Processor %s on Node 
%d".formatted(processorId, nodeIndex));
+        } finally {
+            switchClientToNode(1);
+        }
+    }
+
+    private void assertProcessorStateOnAllNodes(final String processorId, 
final ScheduledState expectedState)
+            throws NiFiClientException, IOException {
+        try {
+            for (int nodeIndex = 1; nodeIndex <= 2; nodeIndex++) {
+                switchClientToNode(nodeIndex);
+                final ProcessorEntity processor = 
getNifiClient().getProcessorClient(DO_NOT_REPLICATE).getProcessor(processorId);
+                assertEquals(expectedState.name(), 
processor.getComponent().getState(),
+                        "Unexpected state for Processor %s on Node 
%d".formatted(processorId, nodeIndex));
+            }
+        } finally {
+            switchClientToNode(1);
+        }
+    }
+
+    private void assertStatelessGroupStateOnAllNodes(final String groupId, 
final StatelessGroupScheduledState expectedState)
+            throws NiFiClientException, IOException {
+        try {
+            for (int nodeIndex = 1; nodeIndex <= 2; nodeIndex++) {
+                switchClientToNode(nodeIndex);
+                final ProcessGroupEntity group = 
getNifiClient().getProcessGroupClient(DO_NOT_REPLICATE).getProcessGroup(groupId);
+                assertEquals(expectedState.name(), 
group.getComponent().getStatelessGroupScheduledState(),
+                        "Unexpected state for Stateless Process Group %s on 
Node %d".formatted(groupId, nodeIndex));
+            }
+        } finally {
+            switchClientToNode(1);
+        }
+    }
+
+    private void assertConflict(final NiFiClientException exception) {
+        final Throwable cause = exception.getCause();
+        if (cause instanceof final WebApplicationException 
webApplicationException) {
+            assertEquals(409, 
webApplicationException.getResponse().getStatus());
+            return;
+        }
+
+        fail("Expected WebApplicationException 409, got: " + cause);
+    }
+
+    private record MixedEngineFlow(
+            ProcessGroupEntity parentGroup,
+            ProcessGroupEntity standardGroup,
+            ProcessGroupEntity statelessGroup,
+            ProcessorEntity standardSource,
+            ProcessorEntity standardDownstream,
+            ProcessorEntity statelessSource,
+            ProcessorEntity statelessDownstream) {
+    }
+}
diff --git 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/FlowClient.java
 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/FlowClient.java
index 5218f17bf3c..a2c0818f4f3 100644
--- 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/FlowClient.java
+++ 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/FlowClient.java
@@ -85,6 +85,16 @@ public interface FlowClient {
     ScheduleComponentsEntity scheduleProcessGroupComponents(
             String processGroupId, ScheduleComponentsEntity 
scheduleComponentsEntity) throws NiFiClientException, IOException;
 
+    /**
+     * Stops source components in a process group.
+     *
+     * @param processGroupId the id of a process group
+     * @param scheduleComponentsEntity the scheduled state to update to
+     * @return the entity representing the stopped source components
+     */
+    ScheduleComponentsEntity stopProcessGroupSources(
+            String processGroupId, ScheduleComponentsEntity 
scheduleComponentsEntity) throws NiFiClientException, IOException;
+
     /**
      * Gets the possible versions for the given flow in the given bucket in the
      * given registry.
diff --git 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyFlowClient.java
 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyFlowClient.java
index acf0a700a53..8a684cbfa9e 100644
--- 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyFlowClient.java
+++ 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyFlowClient.java
@@ -166,6 +166,32 @@ public class JerseyFlowClient extends AbstractJerseyClient 
implements FlowClient
         });
     }
 
+    @Override
+    public ScheduleComponentsEntity stopProcessGroupSources(
+            final String processGroupId, final ScheduleComponentsEntity 
scheduleComponentsEntity)
+            throws NiFiClientException, IOException {
+
+        if (StringUtils.isBlank(processGroupId)) {
+            throw new IllegalArgumentException("Process group id cannot be 
null");
+        }
+
+        if (scheduleComponentsEntity == null) {
+            throw new IllegalArgumentException("ScheduleComponentsEntity 
cannot be null");
+        }
+
+        scheduleComponentsEntity.setId(processGroupId);
+
+        return executeAction("Error stopping process group sources", () -> {
+            final WebTarget target = flowTarget
+                    .path("process-groups/{id}/sources")
+                    .resolveTemplate("id", processGroupId);
+
+            return getRequestBuilder(target).put(
+                    Entity.entity(scheduleComponentsEntity, 
MediaType.APPLICATION_JSON_TYPE),
+                    ScheduleComponentsEntity.class);
+        });
+    }
+
     @Override
     public VersionedFlowSnapshotMetadataSetEntity getVersions(final String 
registryId, final String bucketId, final String flowId)
             throws NiFiClientException, IOException {

Reply via email to