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

pvillard31 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 f492db48779 NIFI-16494 Implemented partial component state updates in 
framework (#11837)
f492db48779 is described below

commit f492db48779e630118ee1872f9f95ecad135d146
Author: Mark Payne <[email protected]>
AuthorDate: Wed Oct 28 12:43:37 2026 -0400

    NIFI-16494 Implemented partial component state updates in framework (#11837)
---
 nifi-docs/src/main/asciidoc/developer-guide.adoc   |   6 +-
 .../groovyx/flow/ProcessSessionWrap.java           |   5 +
 .../nifi/components/state/StateProvider.java       |  23 +++
 .../KubernetesConfigMapStateProviderTest.java      |   6 +
 .../controller/repository/SessionStateUpdate.java  |  75 +++++++++
 .../repository/StandardProcessSession.java         | 179 ++++++++++++---------
 .../WeakHashMapProcessSessionFactory.java          |   5 +
 .../controller/state/StandardStateManager.java     |   6 +
 .../manager/StandardStateManagerProvider.java      |   7 +
 .../TaskTerminationAwareStateManager.java          |   6 +
 .../repository/BatchingSessionFactory.java         |   7 +-
 .../nifi/controller/state/StateMapSerDe.java       |  65 +++++++-
 .../nifi/controller/state/StateMapUpdate.java      |  21 +++
 .../state/providers/ManagedStateProvider.java      |   7 +
 .../local/WriteAheadLocalStateProvider.java        |  40 ++++-
 .../repository/StandardProcessSessionIT.java       | 103 +++++++++++-
 .../nifi/controller/state/TestStateMapSerDe.java   |  60 +++++++
 .../state/providers/AbstractTestStateProvider.java |  18 +++
 .../local/TestWriteAheadLocalStateProvider.java    |  64 ++++++++
 .../org/apache/nifi/util/MockProcessSession.java   |  81 +++++++++-
 .../apache/nifi/util/TestMockProcessSession.java   |  42 +++++
 .../system/VerifyLocalClusterStateService.java     |   5 +-
 .../processors/tests/system/MultiKeyState.java     |   2 +-
 .../tests/system/state/ClusterStateKeyDropIT.java  |   4 +
 24 files changed, 737 insertions(+), 100 deletions(-)

diff --git a/nifi-docs/src/main/asciidoc/developer-guide.adoc 
b/nifi-docs/src/main/asciidoc/developer-guide.adoc
index 8e9fc0ea3b6..608df79a20b 100644
--- a/nifi-docs/src/main/asciidoc/developer-guide.adoc
+++ b/nifi-docs/src/main/asciidoc/developer-guide.adoc
@@ -706,8 +706,10 @@ that the instance is clustered and write the code 
accordingly.
 
 ==== Storing and Retrieving State
 
-State is stored using the StateManager's `getState`, `setState`, `replace`, 
and `clear` methods. All of these methods
-require that a Scope be provided. It should be noted that the state that is 
stored with the Local scope is entirely different
+State is stored using the StateManager's `getState`, `setState`, `patchState`, 
`replace`, and `clear` methods. All of these methods
+require that a Scope be provided. The `patchState` method changes only the 
keys that it is given: a key with a value is created or
+overwritten, a key with a `null` value is removed, and all other keys are left 
as they are. The local state provider writes only
+the changed keys to disk, which makes `patchState` much cheaper than 
`setState` when a component holds many keys but changes few. It should be noted 
that the state that is stored with the Local scope is entirely different
 than state stored with a Cluster scope. If a Processor stores a value with the 
key of _My Key_ using the `Scope.CLUSTER` scope,
 and then attempts to retrieve the value using the `Scope.LOCAL` scope, the 
value retrieved will be `null` (unless a value was
 also stored with the same key using the `Scope.CLUSTER` scope). Each 
Processor's state is stored in isolation from other
diff --git 
a/nifi-extension-bundles/nifi-groovyx-bundle/nifi-groovyx-processors/src/main/java/org/apache/nifi/processors/groovyx/flow/ProcessSessionWrap.java
 
b/nifi-extension-bundles/nifi-groovyx-bundle/nifi-groovyx-processors/src/main/java/org/apache/nifi/processors/groovyx/flow/ProcessSessionWrap.java
index b2727fc1cc2..dc4a0fe4d1b 100644
--- 
a/nifi-extension-bundles/nifi-groovyx-bundle/nifi-groovyx-processors/src/main/java/org/apache/nifi/processors/groovyx/flow/ProcessSessionWrap.java
+++ 
b/nifi-extension-bundles/nifi-groovyx-bundle/nifi-groovyx-processors/src/main/java/org/apache/nifi/processors/groovyx/flow/ProcessSessionWrap.java
@@ -997,6 +997,11 @@ public abstract class ProcessSessionWrap implements 
ProcessSession {
         return session.replaceState(oldValue, newValue, scope);
     }
 
+    @Override
+    public void patchState(final Map<String, String> updatedState, final Scope 
scope) throws IOException {
+        session.patchState(updatedState, scope);
+    }
+
     @Override
     public void clearState(final Scope scope) throws IOException {
         session.clearState(scope);
diff --git 
a/nifi-framework-api/src/main/java/org/apache/nifi/components/state/StateProvider.java
 
b/nifi-framework-api/src/main/java/org/apache/nifi/components/state/StateProvider.java
index 58fedb29762..347b779c74c 100644
--- 
a/nifi-framework-api/src/main/java/org/apache/nifi/components/state/StateProvider.java
+++ 
b/nifi-framework-api/src/main/java/org/apache/nifi/components/state/StateProvider.java
@@ -23,6 +23,7 @@ import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Map;
+import java.util.Objects;
 
 /**
  * <p>
@@ -92,6 +93,28 @@ public interface StateProvider extends ConfigurableComponent 
{
      */
     boolean replace(StateMap oldValue, Map<String, String> newValue, String 
componentId) throws IOException;
 
+    /**
+     * Updates only the given keys of the component's state, leaving all other 
keys as they are. Each key with a non-null value is
+     * created or overwritten, and each key with a {@code null} value is 
removed. An empty map does nothing: no state is written and the
+     * version does not change.
+     *
+     * @param updatedState a map of state keys to their new values. A key that 
is mapped to {@code null} is removed from the state. The map itself must not be 
{@code null}.
+     * @param componentId the id of the component for which state is being 
updated
+     *
+     * @throws IOException if unable to communicate with the underlying 
storage mechanism
+     */
+    default void patchState(final Map<String, String> updatedState, final 
String componentId) throws IOException {
+        Objects.requireNonNull(updatedState, "Updated State required");
+        if (updatedState.isEmpty()) {
+            return;
+        }
+
+        StateMap currentState;
+        do {
+            currentState = getState(componentId);
+        } while (!replace(currentState, currentState.toMap(updatedState), 
componentId));
+    }
+
     /**
      * Removes all values from the component's state that is stored using the 
given scope
      *
diff --git 
a/nifi-framework-bundle/nifi-framework-extensions/nifi-framework-kubernetes-bundle/nifi-framework-kubernetes-state-provider/src/test/java/org/apache/nifi/kubernetes/state/provider/KubernetesConfigMapStateProviderTest.java
 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-framework-kubernetes-bundle/nifi-framework-kubernetes-state-provider/src/test/java/org/apache/nifi/kubernetes/state/provider/KubernetesConfigMapStateProviderTest.java
index 16a13cd87ae..9c9892ab759 100644
--- 
a/nifi-framework-bundle/nifi-framework-extensions/nifi-framework-kubernetes-bundle/nifi-framework-kubernetes-state-provider/src/test/java/org/apache/nifi/kubernetes/state/provider/KubernetesConfigMapStateProviderTest.java
+++ 
b/nifi-framework-bundle/nifi-framework-extensions/nifi-framework-kubernetes-bundle/nifi-framework-kubernetes-state-provider/src/test/java/org/apache/nifi/kubernetes/state/provider/KubernetesConfigMapStateProviderTest.java
@@ -57,6 +57,7 @@ import static 
org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -307,6 +308,11 @@ class KubernetesConfigMapStateProviderTest {
         final Optional<String> replacedVersion = 
replacedStateMap.getStateVersion();
         assertTrue(replacedVersion.isPresent());
         assertEquals(SECOND_VERSION, replacedVersion.get());
+
+        provider.patchState(Collections.singletonMap(STATE_PROPERTY, 
STATE_VALUE), COMPONENT_ID);
+        final StateMap patchedStateMap = provider.getState(COMPONENT_ID);
+        assertEquals(state, patchedStateMap.toMap());
+        assertNotEquals(replacedVersion, patchedStateMap.getStateVersion());
     }
 
     @Test
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/repository/SessionStateUpdate.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/repository/SessionStateUpdate.java
new file mode 100644
index 00000000000..26747703d8f
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/repository/SessionStateUpdate.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.controller.repository;
+
+import org.apache.nifi.components.state.StateMap;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * A state update that a Process Session holds until it is committed. The 
update always provides the full state as the session sees it.
+ * When the update was made only by patching state, it also provides the 
patches, so that only the patches need to be sent to the
+ * State Manager on commit.
+ */
+final class SessionStateUpdate {
+    private final StateMap stateMap;
+    private final Map<String, String> patches;
+
+    private SessionStateUpdate(final StateMap stateMap, final Map<String, 
String> patches) {
+        this.stateMap = stateMap;
+        this.patches = patches;
+    }
+
+    /**
+     * Creates an update that replaces the whole state with the given state
+     *
+     * @param stateMap the new state
+     * @return an update that replaces the whole state
+     */
+    static SessionStateUpdate createWholeStateUpdate(final StateMap stateMap) {
+        return new SessionStateUpdate(stateMap, null);
+    }
+
+    /**
+     * Creates an update that changes only the keys in the given patches
+     *
+     * @param stateMap the full state that results from applying the patches
+     * @param patches the keys to change, where a {@code null} value means 
that the key is removed
+     * @return an update that changes only the keys in the given patches
+     */
+    static SessionStateUpdate createPatchUpdate(final StateMap stateMap, final 
Map<String, String> patches) {
+        // Map.copyOf does not allow null values, which are needed to indicate 
removed keys
+        return new SessionStateUpdate(stateMap, 
Collections.unmodifiableMap(new HashMap<>(patches)));
+    }
+
+    /**
+     * @return the full state as the session sees it once this update is 
applied
+     */
+    StateMap getStateMap() {
+        return stateMap;
+    }
+
+    /**
+     * @return an unmodifiable map of the keys to change, where a {@code null} 
value means that the key is removed, or {@code null} if this update
+     *         replaces the whole state
+     */
+    Map<String, String> getPatches() {
+        return patches;
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/repository/StandardProcessSession.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/repository/StandardProcessSession.java
index 0a6d214fa06..1175b636d44 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/repository/StandardProcessSession.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/repository/StandardProcessSession.java
@@ -140,8 +140,6 @@ public class StandardProcessSession implements 
ProcessSession, ProvenanceEventEn
     private static final String CONNECTION_COMPONENT_TYPE = "Connection";
     private static final AtomicLong idGenerator = new AtomicLong(0L);
     private static final AtomicLong enqueuedIndex = new AtomicLong(0L);
-    private static final StateMap EMPTY_STATE_MAP = new 
StandardStateMap(Collections.emptyMap(), Optional.empty());
-
     // determines how many things must be transferred, removed, modified in 
order to avoid logging the FlowFile ID's on commit/rollback
     public static final int VERBOSE_LOG_THRESHOLD = 10;
     public static final String DEFAULT_FLOWFILE_PATH = "./";
@@ -205,8 +203,8 @@ public class StandardProcessSession implements 
ProcessSession, ProvenanceEventEn
     private Checkpoint checkpoint = null;
     private final ContentClaimWriteCache claimCache;
 
-    private StateMap localState;
-    private StateMap clusterState;
+    private SessionStateUpdate localStateUpdate;
+    private SessionStateUpdate clusterStateUpdate;
     private final String retryAttribute;
     private final FlowFileLinkage flowFileLinkage = new FlowFileLinkage();
 
@@ -719,40 +717,9 @@ public class StandardProcessSession implements 
ProcessSession, ProvenanceEventEn
                 LOG.debug("{}", timingInfo);
             }
 
-            // Update local state
             final StateManager stateManager = context.getStateManager();
-            if (checkpoint.localState != null) {
-                try {
-                    final StateMap stateMap = 
stateManager.getState(Scope.LOCAL);
-                    final Optional<String> stateVersion = 
stateMap.getStateVersion();
-                    if 
(!stateVersion.equals(checkpoint.localState.getStateVersion())) {
-                        LOG.debug("Updating State Manager's Local State");
-                        stateManager.setState(checkpoint.localState.toMap(), 
Scope.LOCAL);
-                    } else {
-                        LOG.debug("Will not update State Manager's Local State 
because the State Manager reports the latest version as {}, which is newer than 
the session's known version of {}.",
-                                stateVersion, 
checkpoint.localState.getStateVersion());
-                    }
-                } catch (final Exception e) {
-                    LOG.warn("Failed to update Local State for {}. If NiFi is 
restarted before the state is able to be updated, it could result in data 
duplication.", connectableDescription, e);
-                }
-            }
-
-            // Update cluster state
-            if (checkpoint.clusterState != null) {
-                try {
-                    final StateMap stateMap = 
stateManager.getState(Scope.CLUSTER);
-                    final Optional<String> stateVersion = 
stateMap.getStateVersion();
-                    if 
(!stateVersion.equals(checkpoint.clusterState.getStateVersion())) {
-                        LOG.debug("Updating State Manager's Cluster State");
-                        stateManager.setState(checkpoint.clusterState.toMap(), 
Scope.CLUSTER);
-                    } else {
-                        LOG.debug("Will not update State Manager's Cluster 
State because the State Manager reports the latest version as {}, which is 
newer than the session's known version of {}.",
-                                stateVersion, 
checkpoint.clusterState.getStateVersion());
-                    }
-                } catch (final Exception e) {
-                    LOG.warn("Failed to update Cluster State for {}. If NiFi 
is restarted before the state is able to be updated, it could result in data 
duplication.", connectableDescription, e);
-                }
-            }
+            commitStateUpdate(stateManager, checkpoint.localStateUpdate, 
Scope.LOCAL);
+            commitStateUpdate(stateManager, checkpoint.clusterStateUpdate, 
Scope.CLUSTER);
 
             // Acknowledge records in order to update counts for incoming 
connections' queues
             acknowledgeRecords();
@@ -781,6 +748,40 @@ public class StandardProcessSession implements 
ProcessSession, ProvenanceEventEn
         }
     }
 
+    /**
+     * Sends the session's state update to the State Manager. An update that 
was made only by patching state always sends its patches, because
+     * the patches change only their own keys and so apply on top of whatever 
state is stored at the time of the commit. Any other update sends
+     * the whole state, unless the State Manager already reports the session's 
version.
+     */
+    private void commitStateUpdate(final StateManager stateManager, final 
SessionStateUpdate stateUpdate, final Scope scope) {
+        if (stateUpdate == null) {
+            return;
+        }
+
+        final String scopeName = scope == Scope.LOCAL ? "Local" : "Cluster";
+        try {
+            final Map<String, String> patches = stateUpdate.getPatches();
+            if (patches != null) {
+                LOG.debug("Patching {} keys in State Manager's {} State", 
patches.size(), scopeName);
+                stateManager.patchState(patches, scope);
+                return;
+            }
+
+            final Optional<String> stateVersion = 
stateManager.getState(scope).getStateVersion();
+            final Optional<String> sessionStateVersion = 
stateUpdate.getStateMap().getStateVersion();
+            if (stateVersion.equals(sessionStateVersion)) {
+                LOG.debug("Will not update State Manager's {} State because 
the State Manager reports the latest version as {}, which is newer than the 
session's known version of {}.",
+                        scopeName, stateVersion, sessionStateVersion);
+                return;
+            }
+
+            LOG.debug("Updating State Manager's {} State", scopeName);
+            stateManager.setState(stateUpdate.getStateMap().toMap(), scope);
+        } catch (final Exception e) {
+            LOG.warn("Failed to update {} State for {}. If NiFi is restarted 
before the state is able to be updated, it could result in data duplication.", 
scopeName, connectableDescription, e);
+        }
+    }
+
     private void updateEventRepository(final Checkpoint checkpoint) {
         try {
             // update event repository
@@ -1342,10 +1343,10 @@ public class StandardProcessSession implements 
ProcessSession, ProvenanceEventEn
             LOG.warn("{} Attempted to close Output Stream for {} due to 
session rollback but close failed", this, this.connectableDescription, e1);
         }
 
-        if (localState != null || clusterState != null) {
+        if (localStateUpdate != null || clusterStateUpdate != null) {
             LOG.debug("Rolling back session that has state stored. This state 
will not be updated.");
         }
-        if (rollbackCheckpoint && checkpoint != null && (checkpoint.localState 
!= null || checkpoint.clusterState != null)) {
+        if (rollbackCheckpoint && checkpoint != null && 
(checkpoint.localStateUpdate != null || checkpoint.clusterStateUpdate != null)) 
{
             LOG.debug("Rolling back checkpoint that has state stored. This 
state will not be updated.");
         }
 
@@ -1565,8 +1566,8 @@ public class StandardProcessSession implements 
ProcessSession, ProvenanceEventEn
         forkEventBuilders.clear();
         provenanceReporter.clear();
 
-        localState = null;
-        clusterState = null;
+        localStateUpdate = null;
+        clusterStateUpdate = null;
 
         processingStartTime = System.nanoTime();
     }
@@ -4026,37 +4027,38 @@ public class StandardProcessSession implements 
ProcessSession, ProvenanceEventEn
     }
 
     private void setState(final StateMap stateMap, final Scope scope) {
+        setStateUpdate(SessionStateUpdate.createWholeStateUpdate(stateMap), 
scope);
+    }
+
+    private void setStateUpdate(final SessionStateUpdate stateUpdate, final 
Scope scope) {
         if (scope == Scope.LOCAL) {
-            localState = stateMap;
+            localStateUpdate = stateUpdate;
         } else {
-            clusterState = stateMap;
+            clusterStateUpdate = stateUpdate;
         }
     }
 
     @Override
     public StateMap getState(final Scope scope) throws IOException {
-        if (scope == Scope.LOCAL) {
-            if (localState != null) {
-                return localState;
-            }
-
-            if (checkpoint != null && checkpoint.localState != null) {
-                return checkpoint.localState;
-            }
-
-            // If no state is held locally, get it from the State Manager.
-            return context.getStateManager().getState(scope);
+        final SessionStateUpdate pendingUpdate = getPendingStateUpdate(scope);
+        if (pendingUpdate != null) {
+            return pendingUpdate.getStateMap();
         }
 
-        if (clusterState != null) {
-            return clusterState;
-        }
+        // If no state is held locally, get it from the State Manager.
+        return context.getStateManager().getState(scope);
+    }
 
-        if (checkpoint != null && checkpoint.clusterState != null) {
-            return checkpoint.clusterState;
+    /**
+     * @return the state update held by this session for the given scope, or 
else the one held by the checkpoint, or {@code null} if neither holds one
+     */
+    private SessionStateUpdate getPendingStateUpdate(final Scope scope) {
+        final SessionStateUpdate sessionUpdate = scope == Scope.LOCAL ? 
localStateUpdate : clusterStateUpdate;
+        if (sessionUpdate != null || checkpoint == null) {
+            return sessionUpdate;
         }
 
-        return context.getStateManager().getState(scope);
+        return scope == Scope.LOCAL ? checkpoint.localStateUpdate : 
checkpoint.clusterStateUpdate;
     }
 
     @Override
@@ -4082,9 +4084,42 @@ public class StandardProcessSession implements 
ProcessSession, ProvenanceEventEn
         return false;
     }
 
+    /**
+     * Applies the given changes to the session's view of the state. When the 
session commits, only the patches are sent to the
+     * State Manager, unless the session or its checkpoint already holds a 
whole-state update from {@link #setState(Map, Scope)},
+     * {@link #replaceState(StateMap, Map, Scope)}, or {@link 
#clearState(Scope)}, in which case the whole state is sent.
+     */
+    @Override
+    public void patchState(final Map<String, String> updatedState, final Scope 
scope) throws IOException {
+        Objects.requireNonNull(updatedState, "Updated State required");
+        if (updatedState.isEmpty()) {
+            return;
+        }
+
+        final StateMap currentState = getState(scope);
+        final Map<String, String> patchedValues = 
currentState.toMap(updatedState);
+        final String version = 
currentState.getStateVersion().map(this::getIncrementedVersion).orElse(INITIAL_VERSION);
+        final StateMap patchedStateMap = new StandardStateMap(patchedValues, 
Optional.of(version));
+
+        final SessionStateUpdate pendingUpdate = getPendingStateUpdate(scope);
+        if (pendingUpdate == null) {
+            
setStateUpdate(SessionStateUpdate.createPatchUpdate(patchedStateMap, 
updatedState), scope);
+            return;
+        }
+
+        if (pendingUpdate.getPatches() == null) {
+            setState(patchedStateMap, scope);
+            return;
+        }
+
+        final Map<String, String> combinedPatches = new 
HashMap<>(pendingUpdate.getPatches());
+        combinedPatches.putAll(updatedState);
+        setStateUpdate(SessionStateUpdate.createPatchUpdate(patchedStateMap, 
combinedPatches), scope);
+    }
+
     @Override
-    public void clearState(final Scope scope) {
-        setState(EMPTY_STATE_MAP, scope);
+    public void clearState(final Scope scope) throws IOException {
+        setState(Collections.emptyMap(), scope);
     }
 
     @Override
@@ -4153,8 +4188,8 @@ public class StandardProcessSession implements 
ProcessSession, ProvenanceEventEn
         private long bytesSent = 0L;
 
         private boolean initialized = false;
-        private StateMap localState;
-        private StateMap clusterState;
+        private SessionStateUpdate localStateUpdate;
+        private SessionStateUpdate clusterStateUpdate;
 
         private void initializeForCopy() {
             if (initialized) {
@@ -4228,11 +4263,11 @@ public class StandardProcessSession implements 
ProcessSession, ProvenanceEventEn
             this.flowFilesSent = session.provenanceReporter.getFlowFilesSent();
             this.bytesSent = session.provenanceReporter.getBytesSent();
 
-            if (session.localState != null) {
-                this.localState = session.localState;
+            if (session.localStateUpdate != null) {
+                this.localStateUpdate = session.localStateUpdate;
             }
-            if (session.clusterState != null) {
-                this.clusterState = session.clusterState;
+            if (session.clusterStateUpdate != null) {
+                this.clusterStateUpdate = session.clusterStateUpdate;
             }
         }
 
@@ -4283,11 +4318,11 @@ public class StandardProcessSession implements 
ProcessSession, ProvenanceEventEn
             this.flowFilesSent += 
session.provenanceReporter.getFlowFilesSent();
             this.bytesSent += session.provenanceReporter.getBytesSent();
 
-            if (session.localState != null) {
-                this.localState = session.localState;
+            if (session.localStateUpdate != null) {
+                this.localStateUpdate = session.localStateUpdate;
             }
-            if (session.clusterState != null) {
-                this.clusterState = session.clusterState;
+            if (session.clusterStateUpdate != null) {
+                this.clusterStateUpdate = session.clusterStateUpdate;
             }
         }
 
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/repository/WeakHashMapProcessSessionFactory.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/repository/WeakHashMapProcessSessionFactory.java
index 325d1749035..c12d8d1a7a4 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/repository/WeakHashMapProcessSessionFactory.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/repository/WeakHashMapProcessSessionFactory.java
@@ -345,6 +345,11 @@ public class WeakHashMapProcessSessionFactory implements 
ActiveProcessSessionFac
             return delegate.replaceState(oldValue, newValue, scope);
         }
 
+        @Override
+        public void patchState(final Map<String, String> updatedState, final 
Scope scope) throws IOException {
+            delegate.patchState(updatedState, scope);
+        }
+
         @Override
         public void clearState(final Scope scope) throws IOException {
             delegate.clearState(scope);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/state/StandardStateManager.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/state/StandardStateManager.java
index a3ce131a9a8..5ecf0d7e566 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/state/StandardStateManager.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/state/StandardStateManager.java
@@ -87,6 +87,12 @@ public class StandardStateManager implements StateManager {
         getProvider(scope).setState(state, componentId);
     }
 
+    @Override
+    public void patchState(final Map<String, String> updatedState, final Scope 
scope) throws IOException {
+        getLogger(componentId).debug("Patching {} State with {}", scope, 
updatedState);
+        getProvider(scope).patchState(updatedState, componentId);
+    }
+
     @Override
     public void clear(final Scope scope) throws IOException {
         getLogger(componentId).debug("Clearing {} State", scope);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/state/manager/StandardStateManagerProvider.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/state/manager/StandardStateManagerProvider.java
index ac624224671..e72f1ace837 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/state/manager/StandardStateManagerProvider.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/state/manager/StandardStateManagerProvider.java
@@ -459,6 +459,13 @@ public class StandardStateManagerProvider implements 
StateManagerProvider {
                 }
             }
 
+            @Override
+            public void patchState(Map<String, String> updatedState, String 
componentId) throws IOException {
+                try (final NarCloseable ignored = 
NarCloseable.withNarLoader()) {
+                    stateProvider.patchState(updatedState, componentId);
+                }
+            }
+
             @Override
             public void clear(String componentId) throws IOException {
                 try (final NarCloseable ignored = 
NarCloseable.withNarLoader()) {
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/lifecycle/TaskTerminationAwareStateManager.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/lifecycle/TaskTerminationAwareStateManager.java
index e846628fca4..9948c9a723d 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/lifecycle/TaskTerminationAwareStateManager.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/lifecycle/TaskTerminationAwareStateManager.java
@@ -63,4 +63,10 @@ public class TaskTerminationAwareStateManager implements 
StateManager {
         verifyNotTerminated();
         stateManager.setState(state, scope);
     }
+
+    @Override
+    public void patchState(final Map<String, String> updatedState, final Scope 
scope) throws IOException {
+        verifyNotTerminated();
+        stateManager.patchState(updatedState, scope);
+    }
 }
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/repository/BatchingSessionFactory.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/repository/BatchingSessionFactory.java
index b3a373d85d3..0a055613a11 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/repository/BatchingSessionFactory.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/repository/BatchingSessionFactory.java
@@ -312,7 +312,12 @@ public class BatchingSessionFactory implements 
ProcessSessionFactory {
         }
 
         @Override
-        public void clearState(final Scope scope) {
+        public void patchState(final Map<String, String> updatedState, final 
Scope scope) throws IOException {
+            session.patchState(updatedState, scope);
+        }
+
+        @Override
+        public void clearState(final Scope scope) throws IOException {
             session.clearState(scope);
         }
 
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/StateMapSerDe.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/StateMapSerDe.java
index 012e7ed9a08..ffa688da1d9 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/StateMapSerDe.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/StateMapSerDe.java
@@ -28,14 +28,33 @@ import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
 
+/**
+ * Serializes component state for the write-ahead log. Full records, which are 
used for snapshots, always hold the whole state map.
+ * Starting with version 1, an edit in the journal holds either the whole 
state map or only the patches. An edit that holds
+ * only the patches is applied on top of the component's previous record when 
the journal is recovered. Version 0 edits always
+ * hold the whole state map, in the same layout as full records.
+ */
 public class StateMapSerDe implements SerDe<StateMapUpdate> {
     private static final long EMPTY_VERSION = -1;
 
-    private static final int VERSION = 0;
+    private static final int PATCHES_VERSION = 1;
+    private static final int VERSION = PATCHES_VERSION;
 
     @Override
     public void serializeEdit(final StateMapUpdate previousRecordState, final 
StateMapUpdate newRecordState, final DataOutputStream out) throws IOException {
-        serializeRecord(newRecordState, out);
+        out.writeUTF(newRecordState.getComponentId());
+        out.writeUTF(newRecordState.getUpdateType().name());
+        if (newRecordState.getUpdateType() == UpdateType.DELETE) {
+            return;
+        }
+
+        // Only the patches can be written if the journal is able to recover 
the previous record to apply them to
+        final boolean writePatchesOnly = newRecordState.getPatches() != null 
&& previousRecordState != null && previousRecordState.getStateMap() != null;
+        out.writeBoolean(writePatchesOnly);
+
+        final StateMap stateMap = newRecordState.getStateMap();
+        
out.writeLong(stateMap.getStateVersion().map(Long::parseLong).orElse(EMPTY_VERSION));
+        writeEntries(writePatchesOnly ? newRecordState.getPatches() : 
stateMap.toMap(), out);
     }
 
     @Override
@@ -49,8 +68,10 @@ public class StateMapSerDe implements SerDe<StateMapUpdate> {
         final StateMap stateMap = record.getStateMap();
         final long recordVersion = 
stateMap.getStateVersion().map(Long::parseLong).orElse(EMPTY_VERSION);
         out.writeLong(recordVersion);
+        writeEntries(stateMap.toMap(), out);
+    }
 
-        final Map<String, String> map = stateMap.toMap();
+    private void writeEntries(final Map<String, String> map, final 
DataOutputStream out) throws IOException {
         out.writeInt(map.size());
         for (final Map.Entry<String, String> entry : map.entrySet()) {
             final boolean hasKey = entry.getKey() != null;
@@ -69,7 +90,30 @@ public class StateMapSerDe implements SerDe<StateMapUpdate> {
 
     @Override
     public StateMapUpdate deserializeEdit(final DataInputStream in, final 
Map<Object, StateMapUpdate> currentRecordStates, final int version) throws 
IOException {
-        return deserializeRecord(in, version);
+        if (version < PATCHES_VERSION) {
+            return deserializeRecord(in, version);
+        }
+
+        final String componentId = in.readUTF();
+        final UpdateType updateType = UpdateType.valueOf(in.readUTF());
+        if (updateType == UpdateType.DELETE) {
+            return new StateMapUpdate(null, componentId, updateType);
+        }
+
+        final boolean patchesOnly = in.readBoolean();
+        final String stateVersion = String.valueOf(in.readLong());
+        final Map<String, String> entries = readEntries(in);
+        if (!patchesOnly) {
+            return new StateMapUpdate(new StandardStateMap(entries, 
Optional.of(stateVersion)), componentId, updateType);
+        }
+
+        final StateMapUpdate previousRecord = 
currentRecordStates.get(componentId);
+        if (previousRecord == null || previousRecord.getStateMap() == null) {
+            throw new IOException("Journal holds state patches for component " 
+ componentId + " but no previous state was recovered for that component");
+        }
+
+        final Map<String, String> patchedState = 
previousRecord.getStateMap().toMap(entries);
+        return new StateMapUpdate(new StandardStateMap(patchedState, 
Optional.of(stateVersion)), componentId, updateType);
     }
 
     @Override
@@ -82,18 +126,23 @@ public class StateMapSerDe implements 
SerDe<StateMapUpdate> {
         }
 
         final long recordVersion = in.readLong();
+        final Map<String, String> stateValues = readEntries(in);
+        final String stateVersion = String.valueOf(recordVersion);
+        return new StateMapUpdate(new StandardStateMap(stateValues, 
Optional.of(stateVersion)), componentId, updateType);
+    }
+
+    private Map<String, String> readEntries(final DataInputStream in) throws 
IOException {
         final int numEntries = in.readInt();
-        final Map<String, String> stateValues = new HashMap<>(numEntries);
+        final Map<String, String> entries = new HashMap<>(numEntries);
         for (int i = 0; i < numEntries; i++) {
             final boolean hasKey = in.readBoolean();
             final String key = hasKey ? in.readUTF() : null;
             final boolean hasValue = in.readBoolean();
             final String value = hasValue ? in.readUTF() : null;
-            stateValues.put(key, value);
+            entries.put(key, value);
         }
 
-        final String stateVersion = String.valueOf(recordVersion);
-        return new StateMapUpdate(new StandardStateMap(stateValues, 
Optional.of(stateVersion)), componentId, updateType);
+        return entries;
     }
 
     @Override
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/StateMapUpdate.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/StateMapUpdate.java
index 9eb478bc91c..9318ff18ca5 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/StateMapUpdate.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/StateMapUpdate.java
@@ -20,15 +20,29 @@ package org.apache.nifi.controller.state;
 import org.apache.nifi.components.state.StateMap;
 import org.wali.UpdateType;
 
+import java.util.Map;
+
 public class StateMapUpdate {
     private final StateMap stateMap;
     private final String componentId;
     private final UpdateType updateType;
+    private final Map<String, String> patches;
 
     public StateMapUpdate(final StateMap stateMap, final String componentId, 
final UpdateType updateType) {
+        this(stateMap, componentId, updateType, null);
+    }
+
+    /**
+     * @param stateMap the full state of the component once the update is 
applied
+     * @param componentId the identifier of the component
+     * @param updateType the type of update
+     * @param patches the keys that were changed, where a {@code null} value 
means that the key was removed, or {@code null} if the update replaces the 
whole state
+     */
+    public StateMapUpdate(final StateMap stateMap, final String componentId, 
final UpdateType updateType, final Map<String, String> patches) {
         this.stateMap = stateMap;
         this.componentId = componentId;
         this.updateType = updateType;
+        this.patches = patches;
     }
 
     public StateMap getStateMap() {
@@ -42,4 +56,11 @@ public class StateMapUpdate {
     public UpdateType getUpdateType() {
         return updateType;
     }
+
+    /**
+     * @return the keys that were changed, where a {@code null} value means 
that the key was removed, or {@code null} if the update replaces the whole state
+     */
+    public Map<String, String> getPatches() {
+        return patches;
+    }
 }
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/providers/ManagedStateProvider.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/providers/ManagedStateProvider.java
index ab196b29e06..3b6bcacad68 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/providers/ManagedStateProvider.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/providers/ManagedStateProvider.java
@@ -140,6 +140,13 @@ public class ManagedStateProvider implements StateProvider 
{
         }
     }
 
+    @Override
+    public void patchState(final Map<String, String> updatedState, final 
String componentId) throws IOException {
+        try (final NarCloseable ignored = NarCloseable.withNarLoader()) {
+            stateProvider.patchState(updatedState, componentId);
+        }
+    }
+
     @Override
     public void clear(final String componentId) throws IOException {
         try (final NarCloseable ignored = NarCloseable.withNarLoader()) {
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/providers/local/WriteAheadLocalStateProvider.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/providers/local/WriteAheadLocalStateProvider.java
index 1eb68ac2595..1babf959422 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/providers/local/WriteAheadLocalStateProvider.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/state/providers/local/WriteAheadLocalStateProvider.java
@@ -44,6 +44,7 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
@@ -230,6 +231,14 @@ public class WriteAheadLocalStateProvider extends 
AbstractStateProvider {
         return getProvider(componentId).replace(oldValue, newValue);
     }
 
+    /**
+     * Writes only the patches to the write-ahead log's journal. Snapshots 
taken at each checkpoint still hold the whole state.
+     */
+    @Override
+    public void patchState(final Map<String, String> updatedState, final 
String componentId) throws IOException {
+        getProvider(componentId).patchState(updatedState);
+    }
+
     @Override
     public void clear(final String componentId) throws IOException {
         getProvider(componentId).clear();
@@ -280,10 +289,11 @@ public class WriteAheadLocalStateProvider extends 
AbstractStateProvider {
         // Additionally, the implementation of WriteAheadRepository that we 
are using requires that only a single thread update the
         // repository at a time for a record with the same key. I.e., many 
threads can update the repository at once, as long as they
         // are not updating the repository with records that have the same 
identifier.
+        // The local stateMap variable is updated only after the WAL update 
succeeds, so that it always matches the WAL. A journal entry
+        // that holds only patches is applied to the previous state in the WAL 
when the journal is recovered.
         public synchronized void setState(final Map<String, String> state) 
throws IOException {
-            stateMap = new StandardStateMap(state, 
Optional.of(getIncrementedVersion()));
-            final StateMapUpdate updateRecord = new StateMapUpdate(stateMap, 
componentId, UpdateType.UPDATE);
-            wal.update(Collections.singleton(updateRecord), alwaysSync);
+            final StateMap updatedStateMap = new StandardStateMap(state, 
Optional.of(getIncrementedVersion()));
+            writeUpdate(new StateMapUpdate(updatedStateMap, componentId, 
UpdateType.UPDATE));
         }
 
         // see above explanation as to why this method is synchronized.
@@ -292,16 +302,30 @@ public class WriteAheadLocalStateProvider extends 
AbstractStateProvider {
                 return false;
             }
 
-            stateMap = new StandardStateMap(new HashMap<>(newValue), 
Optional.of(getIncrementedVersion()));
-            final StateMapUpdate updateRecord = new StateMapUpdate(stateMap, 
componentId, UpdateType.UPDATE);
-            wal.update(Collections.singleton(updateRecord), alwaysSync);
+            final StateMap updatedStateMap = new StandardStateMap(new 
HashMap<>(newValue), Optional.of(getIncrementedVersion()));
+            writeUpdate(new StateMapUpdate(updatedStateMap, componentId, 
UpdateType.UPDATE));
             return true;
         }
 
+        // see above explanation as to why this method is synchronized.
+        public synchronized void patchState(final Map<String, String> 
updatedState) throws IOException {
+            Objects.requireNonNull(updatedState, "Updated State required");
+            if (updatedState.isEmpty()) {
+                return;
+            }
+
+            final StateMap updatedStateMap = new 
StandardStateMap(stateMap.toMap(updatedState), 
Optional.of(getIncrementedVersion()));
+            writeUpdate(new StateMapUpdate(updatedStateMap, componentId, 
UpdateType.UPDATE, Collections.unmodifiableMap(new HashMap<>(updatedState))));
+        }
+
         public synchronized void clear() throws IOException {
-            stateMap = new StandardStateMap(null, 
Optional.of(getIncrementedVersion()));
-            final StateMapUpdate update = new StateMapUpdate(stateMap, 
componentId, UpdateType.UPDATE);
+            final StateMap updatedStateMap = new StandardStateMap(null, 
Optional.of(getIncrementedVersion()));
+            writeUpdate(new StateMapUpdate(updatedStateMap, componentId, 
UpdateType.UPDATE));
+        }
+
+        private void writeUpdate(final StateMapUpdate update) throws 
IOException {
             wal.update(Collections.singleton(update), alwaysSync);
+            stateMap = update.getStateMap();
         }
 
         private String getIncrementedVersion() {
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/repository/StandardProcessSessionIT.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/repository/StandardProcessSessionIT.java
index 4f1129d72ca..25261c982a0 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/repository/StandardProcessSessionIT.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/repository/StandardProcessSessionIT.java
@@ -118,9 +118,11 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.ArgumentMatchers.notNull;
+import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doThrow;
 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;
@@ -229,7 +231,7 @@ public class StandardProcessSessionIT {
         contentRepo.initialize(new StandardContentRepositoryContext(new 
StandardResourceClaimManager(), EventReporter.NO_OP));
         flowFileRepo = new MockFlowFileRepository(contentRepo);
 
-        stateManager = new MockStateManager(connectable);
+        stateManager = spy(new MockStateManager(connectable));
         stateManager.setIgnoreAnnotations(true);
 
         context = new StandardRepositoryContext(connectable, new 
AtomicLong(0L), contentRepo, flowFileRepo, flowFileEventRepository,
@@ -2494,6 +2496,12 @@ public class StandardProcessSessionIT {
         stateManager.assertStateEquals("abc", "123", Scope.LOCAL);
         stateManager.assertStateNotSet(Scope.CLUSTER);
 
+        // Clearing the state and then patching it replaces the stored state 
with the patched values
+        session.clearState(Scope.LOCAL);
+        session.patchState(Map.of("new", "2"), Scope.LOCAL);
+        session.commit();
+        stateManager.assertStateEquals(Map.of("new", "2"), Scope.LOCAL);
+
         stateManager.reset();
 
         session.setState(Collections.singletonMap("abc", "123"), 
Scope.CLUSTER);
@@ -2502,6 +2510,43 @@ public class StandardProcessSessionIT {
         session.commit();
         stateManager.assertStateEquals("abc", "123", Scope.CLUSTER);
         stateManager.assertStateNotSet(Scope.LOCAL);
+
+        stateManager.reset();
+        session.patchState(Collections.emptyMap(), Scope.LOCAL);
+        session.commit();
+        stateManager.assertStateNotSet();
+
+        // Patches made in one session are combined and sent to the State 
Manager as a single patch
+        session.setState(Collections.singletonMap("abc", "123"), Scope.LOCAL);
+        session.commit();
+        clearInvocations(stateManager);
+
+        session.patchState(Map.of("def", "456"), Scope.LOCAL);
+        final Map<String, String> secondPatch = new HashMap<>();
+        secondPatch.put("def", "789");
+        secondPatch.put("abc", null);
+        session.patchState(secondPatch, Scope.LOCAL);
+        stateManager.assertStateEquals(Map.of("abc", "123"), Scope.LOCAL);
+
+        session.commit();
+        verify(stateManager).patchState(secondPatch, Scope.LOCAL);
+        verify(stateManager, never()).setState(any(), any());
+        stateManager.assertStateEquals(Map.of("def", "789"), Scope.LOCAL);
+
+        // A patch is applied on commit even if another writer updated the 
state after the session retrieved it
+        session.patchState(Map.of("ghi", "1"), Scope.LOCAL);
+        stateManager.setState(Map.of("def", "789", "other", "1"), Scope.LOCAL);
+        session.commit();
+        stateManager.assertStateEquals(Map.of("def", "789", "other", "1", 
"ghi", "1"), Scope.LOCAL);
+
+        // A whole-state update made after a patch sends the whole state
+        clearInvocations(stateManager);
+        session.patchState(Map.of("ghi", "1"), Scope.LOCAL);
+        session.setState(Map.of("xyz", "1"), Scope.LOCAL);
+        session.commit();
+        verify(stateManager).setState(Map.of("xyz", "1"), Scope.LOCAL);
+        verify(stateManager, never()).patchState(any(), any());
+        stateManager.assertStateEquals(Map.of("xyz", "1"), Scope.LOCAL);
     }
 
     @Test
@@ -2554,16 +2599,33 @@ public class StandardProcessSessionIT {
         retrieved = session.getState(Scope.LOCAL);
         assertNotNull(retrieved);
         assertTrue(retrieved.getStateVersion().isPresent());
+
+        stateManager.reset();
+        final Map<String, String> patch = new HashMap<>();
+        patch.put("x", "1");
+        patch.put("y", null);
+        session.patchState(patch, Scope.LOCAL);
+        retrieved = session.getState(Scope.LOCAL);
+        assertEquals(Map.of("x", "1"), retrieved.toMap());
+        assertTrue(retrieved.getStateVersion().isPresent());
+        stateManager.assertStateNotSet();
+
+        session.patchState(Map.of("z", "2"), Scope.LOCAL);
+        assertEquals(Map.of("x", "1", "z", "2"), 
session.getState(Scope.LOCAL).toMap());
+        session.commit();
+        stateManager.assertStateEquals(Map.of("x", "1", "z", "2"), 
Scope.LOCAL);
     }
 
     @Test
     public void testRollbackDoesNotStoreState() throws IOException {
         session.setState(Collections.singletonMap("abc", "1"), Scope.LOCAL);
+        session.patchState(Map.of("def", "2"), Scope.CLUSTER);
         session.rollback();
         stateManager.assertStateNotSet();
 
         session.commit();
         stateManager.assertStateNotSet();
+        verify(stateManager, never()).patchState(any(), any());
     }
 
     @Test
@@ -2575,6 +2637,17 @@ public class StandardProcessSessionIT {
         session.commit();
 
         stateManager.assertStateEquals("abc", "1", Scope.LOCAL);
+
+        // Patches made before and after a checkpoint are sent together as one 
patch
+        stateManager.reset();
+        clearInvocations(stateManager);
+        session.patchState(Map.of("abc", "1"), Scope.LOCAL);
+        session.checkpoint();
+        session.patchState(Map.of("def", "2"), Scope.LOCAL);
+        session.commit();
+
+        verify(stateManager).patchState(Map.of("abc", "1", "def", "2"), 
Scope.LOCAL);
+        stateManager.assertStateEquals(Map.of("abc", "1", "def", "2"), 
Scope.LOCAL);
     }
 
     @Test
@@ -2587,6 +2660,18 @@ public class StandardProcessSessionIT {
 
         session.commit();
         stateManager.assertStateEquals("abc", "2", Scope.LOCAL);
+
+        // A patch made after a checkpointed whole-state update sends the 
whole state
+        stateManager.reset();
+        clearInvocations(stateManager);
+        session.setState(Map.of("abc", "1"), Scope.LOCAL);
+        session.checkpoint();
+        session.patchState(Map.of("def", "2"), Scope.LOCAL);
+        session.commit();
+
+        verify(stateManager).setState(Map.of("abc", "1", "def", "2"), 
Scope.LOCAL);
+        verify(stateManager, never()).patchState(any(), any());
+        stateManager.assertStateEquals(Map.of("abc", "1", "def", "2"), 
Scope.LOCAL);
     }
 
     @Test
@@ -2597,12 +2682,26 @@ public class StandardProcessSessionIT {
         session.checkpoint();
 
         session.setState(Collections.singletonMap("abc", "2"), Scope.LOCAL);
+        session.patchState(Map.of("def", "2"), Scope.LOCAL);
         session.rollback();
 
         stateManager.assertStateNotSet();
         session.commit();
 
-        stateManager.assertStateEquals("abc", "1", Scope.LOCAL);
+        stateManager.assertStateEquals(Map.of("abc", "1"), Scope.LOCAL);
+        verify(stateManager, never()).patchState(any(), any());
+
+        // Rolling back discards a patch made after the checkpoint and keeps 
the checkpointed patch
+        stateManager.reset();
+        clearInvocations(stateManager);
+        session.patchState(Map.of("abc", "1"), Scope.LOCAL);
+        session.checkpoint();
+        session.patchState(Map.of("def", "2"), Scope.LOCAL);
+        session.rollback();
+        session.commit();
+
+        verify(stateManager).patchState(Map.of("abc", "1"), Scope.LOCAL);
+        stateManager.assertStateEquals(Map.of("abc", "1"), Scope.LOCAL);
     }
 
     @Test
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/TestStateMapSerDe.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/TestStateMapSerDe.java
index 206e3fb1e51..f4ad606d229 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/TestStateMapSerDe.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/TestStateMapSerDe.java
@@ -32,6 +32,7 @@ import java.util.Optional;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 public class TestStateMapSerDe {
@@ -69,5 +70,64 @@ public class TestStateMapSerDe {
         assertTrue(stateVersion.isPresent());
         assertEquals(version, stateVersion.get());
         assertEquals(stateValues, recoveredStateMap.toMap());
+
+        // Journals written with version 0 hold edits in the same layout as 
full records
+        final StateMapUpdate versionZeroEdit = deserializeEdit(serde, 
baos.toByteArray(), Map.of(), 0);
+        assertEquals(componentId, versionZeroEdit.getComponentId());
+        assertEquals(stateValues, versionZeroEdit.getStateMap().toMap());
+        assertEquals(Optional.of(version), 
versionZeroEdit.getStateMap().getStateVersion());
+    }
+
+    @Test
+    public void testPatchEditRoundTrip() throws IOException {
+        final String componentId = "1234";
+        final StateMapSerDe serde = new StateMapSerDe();
+
+        final Map<String, String> largeState = new HashMap<>();
+        for (int i = 0; i < 1000; i++) {
+            largeState.put("key" + i, "value" + i);
+        }
+        final StateMapUpdate previousRecord = new StateMapUpdate(new 
StandardStateMap(largeState, Optional.of("4")), componentId, UpdateType.UPDATE);
+
+        final Map<String, String> patches = new HashMap<>();
+        patches.put("key0", "changed");
+        patches.put("key1", null);
+        final Map<String, String> patchedState = new HashMap<>(largeState);
+        patchedState.put("key0", "changed");
+        patchedState.remove("key1");
+        final StandardStateMap patchedStateMap = new 
StandardStateMap(patchedState, Optional.of("5"));
+
+        final byte[] patchEdit = serializeEdit(serde, previousRecord, new 
StateMapUpdate(patchedStateMap, componentId, UpdateType.UPDATE, patches));
+        final byte[] wholeStateEdit = serializeEdit(serde, previousRecord, new 
StateMapUpdate(patchedStateMap, componentId, UpdateType.UPDATE));
+        assertTrue(patchEdit.length * 100 < wholeStateEdit.length);
+
+        // Both kinds of edit recover to the same state when the previous 
record is available
+        final Map<Object, StateMapUpdate> recoveredRecords = 
Map.of(componentId, previousRecord);
+        for (final byte[] edit : new byte[][] {patchEdit, wholeStateEdit}) {
+            final StateMapUpdate recoveredEdit = deserializeEdit(serde, edit, 
recoveredRecords, serde.getVersion());
+            assertEquals(patchedState, recoveredEdit.getStateMap().toMap());
+            assertEquals(Optional.of("5"), 
recoveredEdit.getStateMap().getStateVersion());
+        }
+
+        final IOException noPreviousRecord = assertThrows(IOException.class, 
() -> deserializeEdit(serde, patchEdit, Map.of(), serde.getVersion()));
+        assertTrue(noPreviousRecord.getMessage().contains(componentId));
+
+        // Without a previous record to apply patches to, the whole state is 
written
+        final byte[] firstEdit = serializeEdit(serde, null, new 
StateMapUpdate(patchedStateMap, componentId, UpdateType.UPDATE, patches));
+        assertEquals(patchedState, deserializeEdit(serde, firstEdit, Map.of(), 
serde.getVersion()).getStateMap().toMap());
+    }
+
+    private byte[] serializeEdit(final StateMapSerDe serde, final 
StateMapUpdate previousRecord, final StateMapUpdate record) throws IOException {
+        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (final DataOutputStream out = new DataOutputStream(baos)) {
+            serde.serializeEdit(previousRecord, record, out);
+        }
+        return baos.toByteArray();
+    }
+
+    private StateMapUpdate deserializeEdit(final StateMapSerDe serde, final 
byte[] edit, final Map<Object, StateMapUpdate> recoveredRecords, final int 
version) throws IOException {
+        try (final DataInputStream in = new DataInputStream(new 
ByteArrayInputStream(edit))) {
+            return serde.deserializeEdit(in, recoveredRecords, version);
+        }
     }
 }
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/providers/AbstractTestStateProvider.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/providers/AbstractTestStateProvider.java
index ec53d427d8c..1ea6e5fe918 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/providers/AbstractTestStateProvider.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/providers/AbstractTestStateProvider.java
@@ -31,6 +31,7 @@ import java.util.Optional;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -87,6 +88,23 @@ public abstract class AbstractTestStateProvider {
         map = provider.getState(componentId);
         assertEquals("value2", map.get(key));
         assertTrue(map.getStateVersion().isPresent());
+
+        final Optional<String> versionBeforeEmptyPatch = map.getStateVersion();
+        provider.patchState(Collections.emptyMap(), componentId);
+        assertEquals(versionBeforeEmptyPatch, 
provider.getState(componentId).getStateVersion());
+
+        final Map<String, String> patch = new HashMap<>();
+        patch.put("other", "");
+        patch.put("missing", null);
+        provider.patchState(patch, componentId);
+        map = provider.getState(componentId);
+        assertEquals(Map.of(key, "value2", "other", ""), map.toMap());
+        assertNotEquals(versionBeforeEmptyPatch, map.getStateVersion());
+
+        final Map<String, String> removal = new HashMap<>();
+        removal.put(key, null);
+        provider.patchState(removal, componentId);
+        assertEquals(Map.of("other", ""), 
provider.getState(componentId).toMap());
     }
 
     @Test
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/providers/local/TestWriteAheadLocalStateProvider.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/providers/local/TestWriteAheadLocalStateProvider.java
index fb11a2eef0f..1112b8104ae 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/providers/local/TestWriteAheadLocalStateProvider.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/controller/state/providers/local/TestWriteAheadLocalStateProvider.java
@@ -20,6 +20,7 @@ package org.apache.nifi.controller.state.providers.local;
 import org.apache.nifi.attribute.expression.language.StandardPropertyValue;
 import org.apache.nifi.components.PropertyDescriptor;
 import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.components.state.StateMap;
 import org.apache.nifi.components.state.StateProvider;
 import org.apache.nifi.components.state.StateProviderInitializationContext;
 import org.apache.nifi.controller.state.StateMapUpdate;
@@ -137,6 +138,69 @@ public class TestWriteAheadLocalStateProvider extends 
AbstractTestStateProvider
         }
     }
 
+    /**
+     * Verifies that patching state writes only the patches to the journal, so 
that many small patches of a large state do not fill
+     * the journal, and that the patched state and its version are recovered 
both from the journal and from a journal applied on top of a snapshot.
+     */
+    @Test
+    public void testPatchStateWritesPatchesAndRecovers() throws IOException {
+        final Path storageDirectory = 
temporaryDirectory.resolve("patch-journal");
+        final String testComponentId = "test-patch-component";
+        final String largeValue = "v".repeat(100);
+
+        final Map<String, String> expectedState = new HashMap<>();
+        for (int i = 0; i < 2000; i++) {
+            expectedState.put("key" + i, largeValue);
+        }
+
+        final StateMap patchedStateMap;
+        final WriteAheadLocalStateProvider firstProvider = 
initializeProvider(storageDirectory.toString(), "1 MB");
+        try {
+            firstProvider.setState(expectedState, testComponentId);
+            for (int i = 0; i < 20; i++) {
+                final Map<String, String> patch = new HashMap<>();
+                patch.put("patched" + i, String.valueOf(i));
+                patch.put("key" + i, null);
+                firstProvider.patchState(patch, testComponentId);
+
+                expectedState.put("patched" + i, String.valueOf(i));
+                expectedState.remove("key" + i);
+            }
+
+            final List<File> journalFiles = getJournalFiles(storageDirectory);
+            assertEquals(1, journalFiles.size());
+            assertEquals("0.journal", journalFiles.getFirst().getName());
+
+            patchedStateMap = firstProvider.getState(testComponentId);
+            assertEquals(expectedState, patchedStateMap.toMap());
+        } finally {
+            firstProvider.shutdown();
+        }
+
+        final StateMap secondPatchedStateMap;
+        final WriteAheadLocalStateProvider secondProvider = 
initializeProvider(storageDirectory.toString(), "1 MB");
+        try {
+            final StateMap recoveredStateMap = 
secondProvider.getState(testComponentId);
+            assertEquals(expectedState, recoveredStateMap.toMap());
+            assertEquals(patchedStateMap.getStateVersion(), 
recoveredStateMap.getStateVersion());
+
+            secondProvider.patchState(Map.of("afterRecovery", "1"), 
testComponentId);
+            expectedState.put("afterRecovery", "1");
+            secondPatchedStateMap = secondProvider.getState(testComponentId);
+        } finally {
+            secondProvider.shutdown();
+        }
+
+        final WriteAheadLocalStateProvider thirdProvider = 
initializeProvider(storageDirectory.toString(), "1 MB");
+        try {
+            final StateMap recoveredStateMap = 
thirdProvider.getState(testComponentId);
+            assertEquals(expectedState, recoveredStateMap.toMap());
+            assertEquals(secondPatchedStateMap.getStateVersion(), 
recoveredStateMap.getStateVersion());
+        } finally {
+            thirdProvider.shutdown();
+        }
+    }
+
     private void updateStateRepeatedly(final StateProvider stateProvider, 
final String stateComponentId, final int iterations) throws IOException {
         for (int i = 0; i < iterations; i++) {
             stateProvider.setState(Collections.singletonMap("iteration", 
String.valueOf(i)), stateComponentId);
diff --git 
a/nifi-mock/src/main/java/org/apache/nifi/util/MockProcessSession.java 
b/nifi-mock/src/main/java/org/apache/nifi/util/MockProcessSession.java
index dcdae4e2280..54a8080c8a5 100644
--- a/nifi-mock/src/main/java/org/apache/nifi/util/MockProcessSession.java
+++ b/nifi-mock/src/main/java/org/apache/nifi/util/MockProcessSession.java
@@ -35,6 +35,7 @@ import org.apache.nifi.processor.io.StreamCallback;
 import org.apache.nifi.processor.metrics.CommitTiming;
 import org.apache.nifi.provenance.ProvenanceReporter;
 import org.apache.nifi.state.MockStateManager;
+import org.apache.nifi.state.MockStateMap;
 import org.junit.jupiter.api.Assertions;
 
 import java.io.ByteArrayInputStream;
@@ -89,6 +90,7 @@ public class MockProcessSession implements ProcessSession {
     // A List of OutputStreams that have been created by calls to {@link 
#write(FlowFile)} and have not yet been closed.
     private final Map<FlowFile, OutputStream> openOutputStreams = new 
HashMap<>();
     private final StateManager stateManager;
+    private final Map<Scope, PendingStatePatches> pendingStatePatches = new 
HashMap<>();
     private final boolean allowSynchronousCommits;
     private final boolean allowRecursiveReads;
     private final boolean failCommit;
@@ -340,6 +342,15 @@ public class MockProcessSession implements ProcessSession {
         closeStreams(openInputStreams, enforceStreamsClosed);
         closeStreams(openOutputStreams, enforceStreamsClosed);
 
+        for (final Map.Entry<Scope, PendingStatePatches> entry : 
pendingStatePatches.entrySet()) {
+            try {
+                stateManager.patchState(entry.getValue().values, 
entry.getKey());
+            } catch (final IOException e) {
+                throw new ProcessException("Failed to patch " + entry.getKey() 
+ " state", e);
+            }
+        }
+        pendingStatePatches.clear();
+
         committed = true;
         beingProcessed.clear();
         currentVersions.clear();
@@ -816,6 +827,8 @@ public class MockProcessSession implements ProcessSession {
 
     @Override
     public void rollback(final boolean penalize) {
+        pendingStatePatches.clear();
+
         //if we've already committed then rollback is basically a no-op
         if (committed) {
             return;
@@ -1405,21 +1418,77 @@ public class MockProcessSession implements 
ProcessSession {
     @Override
     public void setState(final Map<String, String> state, final Scope scope) 
throws IOException {
         stateManager.setState(state, scope);
+        pendingStatePatches.remove(scope);
     }
 
+    /**
+     * Returns the stored state with any patches that have not yet been 
committed applied. Each patch gives the returned state a new version,
+     * as the framework does, so that a state obtained before a patch no 
longer matches in {@link #replaceState(StateMap, Map, Scope)}.
+     */
     @Override
     public StateMap getState(final Scope scope) throws IOException {
-        return stateManager.getState(scope);
+        final StateMap storedState = stateManager.getState(scope);
+        final PendingStatePatches patches = pendingStatePatches.get(scope);
+        if (patches == null) {
+            return storedState;
+        }
+
+        return applyPatches(storedState, patches);
     }
 
+    /**
+     * Replaces the state if the given old value matches the session's view of 
the state. When the session holds patches that have not yet
+     * been committed, the old value must match the stored state with those 
patches applied, and a successful replacement discards the patches.
+     */
     @Override
     public boolean replaceState(final StateMap oldValue, final Map<String, 
String> newValue, final Scope scope) throws IOException {
-        return stateManager.replace(oldValue, newValue, scope);
+        final PendingStatePatches patches = pendingStatePatches.get(scope);
+        if (patches == null) {
+            return stateManager.replace(oldValue, newValue, scope);
+        }
+
+        // The same stored state is used for the comparison and the 
replacement, so that an update stored in between causes the replacement to fail
+        final StateMap storedState = stateManager.getState(scope);
+        final StateMap currentState = applyPatches(storedState, patches);
+        if (oldValue == null || 
!oldValue.getStateVersion().equals(currentState.getStateVersion()) || 
!oldValue.toMap().equals(currentState.toMap())) {
+            return false;
+        }
+
+        final boolean replaced = stateManager.replace(storedState, newValue, 
scope);
+        if (replaced) {
+            pendingStatePatches.remove(scope);
+        }
+        return replaced;
+    }
+
+    private static StateMap applyPatches(final StateMap storedState, final 
PendingStatePatches patches) {
+        final long storedVersion = 
storedState.getStateVersion().map(Long::parseLong).orElse(-1L);
+        return new MockStateMap(storedState.toMap(patches.values), 
storedVersion + patches.count);
+    }
+
+    /**
+     * Holds the given changes until the session is committed, so that rolling 
back the session discards them, as the framework does.
+     * A later call to {@link #setState(Map, Scope)}, {@link 
#replaceState(StateMap, Map, Scope)}, or {@link #clearState(Scope)} discards
+     * the patches that have not yet been committed.
+     */
+    @Override
+    public void patchState(final Map<String, String> updatedState, final Scope 
scope) throws IOException {
+        Objects.requireNonNull(updatedState, "Updated State required");
+        // Retrieving the state verifies that the component is allowed to use 
state in the given scope
+        stateManager.getState(scope);
+        if (updatedState.isEmpty()) {
+            return;
+        }
+
+        final PendingStatePatches patches = 
pendingStatePatches.computeIfAbsent(scope, key -> new PendingStatePatches());
+        patches.values.putAll(updatedState);
+        patches.count++;
     }
 
     @Override
     public void clearState(final Scope scope) throws IOException {
         stateManager.clear(scope);
+        pendingStatePatches.remove(scope);
     }
 
     @Override
@@ -1528,4 +1597,12 @@ public class MockProcessSession implements 
ProcessSession {
             );
         }
     }
+
+    /**
+     * The state changes that the session has not yet committed for one scope, 
along with the number of times that state was patched.
+     */
+    private static final class PendingStatePatches {
+        private final Map<String, String> values = new HashMap<>();
+        private int count;
+    }
 }
diff --git 
a/nifi-mock/src/test/java/org/apache/nifi/util/TestMockProcessSession.java 
b/nifi-mock/src/test/java/org/apache/nifi/util/TestMockProcessSession.java
index 4eba9e0ca43..6772284ea8c 100644
--- a/nifi-mock/src/test/java/org/apache/nifi/util/TestMockProcessSession.java
+++ b/nifi-mock/src/test/java/org/apache/nifi/util/TestMockProcessSession.java
@@ -37,6 +37,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -573,6 +574,7 @@ public class TestMockProcessSession {
             assertThrows(AssertionError.class, () -> {
                 session.setState(Map.of("key", "value"), Scope.LOCAL);
             }, "Was able to set local state without declaring the being 
stateful");
+            assertThrows(AssertionError.class, () -> 
session.patchState(Map.of("key", "value"), Scope.LOCAL));
             stateManager.assertStateNotSet();
         }
 
@@ -591,6 +593,46 @@ public class TestMockProcessSession {
 
             sessionOfStatefulProcessor.commitAsync();
             assertEquals(expectedState, 
sessionOfStatefulProcessor.getState(Scope.LOCAL).toMap());
+
+            final Map<String, String> patch = new HashMap<>();
+            patch.put("key", null);
+            patch.put("other", "value");
+            sessionOfStatefulProcessor.patchState(patch, Scope.LOCAL);
+            assertEquals(Map.of("other", "value"), 
sessionOfStatefulProcessor.getState(Scope.LOCAL).toMap());
+            stateManagerOfStatefulProcessor.assertStateEquals(expectedState, 
Scope.LOCAL);
+
+            sessionOfStatefulProcessor.rollback();
+            assertEquals(expectedState, 
sessionOfStatefulProcessor.getState(Scope.LOCAL).toMap());
+
+            final StateMap stateBeforeSameValuePatch = 
sessionOfStatefulProcessor.getState(Scope.LOCAL);
+            sessionOfStatefulProcessor.patchState(expectedState, Scope.LOCAL);
+            assertEquals(expectedState, 
sessionOfStatefulProcessor.getState(Scope.LOCAL).toMap());
+            
assertFalse(sessionOfStatefulProcessor.replaceState(stateBeforeSameValuePatch, 
Map.of("replaced", "value"), Scope.LOCAL));
+
+            sessionOfStatefulProcessor.patchState(patch, Scope.LOCAL);
+            
assertTrue(sessionOfStatefulProcessor.replaceState(sessionOfStatefulProcessor.getState(Scope.LOCAL),
 Map.of("replaced", "value"), Scope.LOCAL));
+            sessionOfStatefulProcessor.patchState(Map.of("patched", "value"), 
Scope.LOCAL);
+            sessionOfStatefulProcessor.commitAsync();
+            
stateManagerOfStatefulProcessor.assertStateEquals(Map.of("replaced", "value", 
"patched", "value"), Scope.LOCAL);
+
+            // An update stored by another party right after the session reads 
the state must not be overwritten by a replacement
+            final AtomicBoolean storeUpdateAfterNextRead = new AtomicBoolean();
+            final MockStateManager concurrentlyUpdatedStateManager = new 
MockStateManager(statefulProcessor) {
+                @Override
+                public synchronized StateMap getState(final Scope scope) 
throws IOException {
+                    final StateMap state = super.getState(scope);
+                    if (storeUpdateAfterNextRead.getAndSet(false)) {
+                        super.setState(Map.of("concurrent", "update"), scope);
+                    }
+                    return state;
+                }
+            };
+            final MockProcessSession concurrentlyUpdatedSession = new 
MockProcessSession(sharedStateOfStatefulProcessor, statefulProcessor, 
concurrentlyUpdatedStateManager);
+            concurrentlyUpdatedSession.patchState(patch, Scope.LOCAL);
+            final StateMap patchedState = 
concurrentlyUpdatedSession.getState(Scope.LOCAL);
+            storeUpdateAfterNextRead.set(true);
+            assertFalse(concurrentlyUpdatedSession.replaceState(patchedState, 
Map.of("replaced", "value"), Scope.LOCAL));
+            
concurrentlyUpdatedStateManager.assertStateEquals(Map.of("concurrent", 
"update"), Scope.LOCAL);
         }
 
         @Test
diff --git 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions-services/src/main/java/org/apache/nifi/cs/tests/system/VerifyLocalClusterStateService.java
 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions-services/src/main/java/org/apache/nifi/cs/tests/system/VerifyLocalClusterStateService.java
index 3780bb61aac..d035c73d224 100644
--- 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions-services/src/main/java/org/apache/nifi/cs/tests/system/VerifyLocalClusterStateService.java
+++ 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions-services/src/main/java/org/apache/nifi/cs/tests/system/VerifyLocalClusterStateService.java
@@ -29,7 +29,6 @@ import java.io.File;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Properties;
@@ -71,8 +70,6 @@ public class VerifyLocalClusterStateService extends 
AbstractControllerService im
 
     @Override
     public void setState(final String key, final String value, final Scope 
scope) throws IOException {
-        final Map<String, String> state = new 
HashMap<>(getStateManager().getState(scope).toMap());
-        state.put(key, value);
-        getStateManager().setState(state, scope);
+        getStateManager().patchState(Map.of(key, value), scope);
     }
 }
diff --git 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/MultiKeyState.java
 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/MultiKeyState.java
index 34ea12014de..525093afb9d 100644
--- 
a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/MultiKeyState.java
+++ 
b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/MultiKeyState.java
@@ -64,7 +64,7 @@ public class MultiKeyState extends AbstractProcessor {
         newState.put("b", String.valueOf(b));
         newState.put("c", String.valueOf(c));
         try {
-            session.setState(newState, Scope.CLUSTER);
+            session.patchState(newState, Scope.CLUSTER);
         } catch (IOException e) {
             throw new ProcessException(e);
         }
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/state/ClusterStateKeyDropIT.java
 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/state/ClusterStateKeyDropIT.java
index 5cb341dbeac..cf0ec7016e4 100644
--- 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/state/ClusterStateKeyDropIT.java
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/state/ClusterStateKeyDropIT.java
@@ -128,6 +128,10 @@ public class ClusterStateKeyDropIT extends 
AbstractStateKeyDropIT {
         assertEquals(newState, updatedState);
 
         waitForProcessorState(processorId, Scope.CLUSTER, newState);
+
+        // MultiKeyState patches all three keys, so the dropped key is created 
again alongside the updated keys
+        runProcessorOnce(processor);
+        waitForProcessorState(processorId, Scope.CLUSTER, Map.of("a", "1", 
"b", "2", "c", "2"));
     }
 
     @Test

Reply via email to