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

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


The following commit(s) were added to refs/heads/main by this push:
     new 9148f1ba28d NIFI-15917 Fixed potential corruption with 
modify-after-write on Local State Provider  (#11220)
9148f1ba28d is described below

commit 9148f1ba28d642ce6c070fefbd791c38d9edb4e0
Author: Mark Payne <[email protected]>
AuthorDate: Fri May 8 09:06:06 2026 -0400

    NIFI-15917 Fixed potential corruption with modify-after-write on Local 
State Provider  (#11220)
    
    When creating a StandardStateMap, create a defensive copy of the provided 
map to avoid allowing modifications by the extension after state is stored to 
alter the internal state.
    
    Signed-off-by: David Handermann <[email protected]>
---
 .../nifi/controller/state/StandardStateMap.java    |  8 ++-
 .../local/WriteAheadLocalStateProvider.java        |  7 ++
 .../local/TestWriteAheadLocalStateProvider.java    | 83 +++++++++++++++++-----
 3 files changed, 80 insertions(+), 18 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/state/StandardStateMap.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/state/StandardStateMap.java
index 09fa7e234fc..fa615ea09af 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/state/StandardStateMap.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/controller/state/StandardStateMap.java
@@ -20,6 +20,7 @@ package org.apache.nifi.controller.state;
 import org.apache.nifi.components.state.StateMap;
 
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
 
@@ -30,7 +31,12 @@ public class StandardStateMap implements StateMap {
     private final Optional<String> stateVersion;
 
     public StandardStateMap(final Map<String, String> stateValues, final 
Optional<String> stateVersion) {
-        this.stateValues = Collections.unmodifiableMap(stateValues == null ? 
Collections.emptyMap() : stateValues);
+        // Defensively copy the caller's Map so that this StateMap's contents 
are immutable and
+        // isolated from any subsequent mutations to the caller's reference. 
Extensions that retain
+        // and continue to mutate the Map they pass to setState() across 
invocations would otherwise
+        // race with the framework's checkpoint thread iterating the StateMap 
during snapshot
+        // serialization.
+        this.stateValues = (stateValues == null || stateValues.isEmpty()) ? 
Collections.emptyMap() : Collections.unmodifiableMap(new 
HashMap<>(stateValues));
         this.stateVersion = stateVersion;
     }
 
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 82dea780d9b..83135a8e4b0 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
@@ -168,6 +168,13 @@ public class WriteAheadLocalStateProvider extends 
AbstractStateProvider {
         return properties;
     }
 
+    // Visible for testing. Forces a synchronous checkpoint of the underlying 
Write-Ahead Log so
+    // that tests can deterministically observe the on-disk snapshot without 
waiting for the
+    // scheduled CheckpointTask to run.
+    void checkpoint() throws IOException {
+        writeAheadLog.checkpoint();
+    }
+
     @Override
     public synchronized void shutdown() {
         executor.shutdown();
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 1c4cd1db385..af3c32e7933 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
@@ -28,30 +28,91 @@ import org.apache.nifi.logging.ComponentLog;
 import org.apache.nifi.parameter.ParameterLookup;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 import org.wali.WriteAheadRepository;
 
 import java.io.IOException;
+import java.nio.file.Path;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.Map;
-import java.util.UUID;
 import javax.net.ssl.SSLContext;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
 public class TestWriteAheadLocalStateProvider extends 
AbstractTestStateProvider {
+    @TempDir
+    private Path temporaryDirectory;
+
     private StateProvider provider;
     private WriteAheadRepository<StateMapUpdate> wal;
 
     @BeforeEach
     public void setup() throws IOException {
-        provider = new WriteAheadLocalStateProvider();
+        provider = 
initializeProvider(temporaryDirectory.resolve("setup").toString());
+    }
+
+    @AfterEach
+    public void cleanup() throws IOException {
+        provider.onComponentRemoved(componentId);
+        provider.shutdown();
+
+        if (wal != null) {
+            wal.shutdown();
+        }
+    }
+
+    @Override
+    protected StateProvider getProvider() {
+        return provider;
+    }
+
+    /**
+     * Verifies that recovered state reflects the values that the caller 
passed to
+     * {@link StateProvider#setState(Map, String)} at the moment of that call, 
and is
+     * not influenced by subsequent mutations the caller performs to the same 
Map
+     * instance after returning. This guards against a corruption scenario 
where a
+     * caller (such as an extension that holds onto its own ConcurrentHashMap) 
keeps
+     * mutating its state map between calls to setState while the framework's
+     * checkpoint thread is concurrently serializing a snapshot. The provider 
must
+     * isolate its persisted state from any further mutation of the caller's 
map.
+     */
+    @Test
+    public void testRecoveredStateIsolatedFromPostSetStateMapMutations() 
throws Exception {
+        final String storageDirectory = 
temporaryDirectory.resolve("recovery").toString();
+        final String testComponentId = "test-mutation-isolation-component";
+
+        final WriteAheadLocalStateProvider firstProvider = 
initializeProvider(storageDirectory);
+        final Map<String, String> liveStateValues = new HashMap<>();
+        liveStateValues.put("key1", "value1");
+        firstProvider.setState(liveStateValues, testComponentId);
+
+        liveStateValues.put("key2", "value2");
+        liveStateValues.put("key3", "value3");
+
+        firstProvider.checkpoint();
+        firstProvider.shutdown();
+
+        final WriteAheadLocalStateProvider recoveredProvider = 
initializeProvider(storageDirectory);
+        try {
+            final Map<String, String> recoveredValues = 
recoveredProvider.getState(testComponentId).toMap();
+            assertEquals(Collections.singletonMap("key1", "value1"), 
recoveredValues);
+        } finally {
+            recoveredProvider.shutdown();
+        }
+    }
+
+    private WriteAheadLocalStateProvider initializeProvider(final String 
storageDirectory) throws IOException {
+        final WriteAheadLocalStateProvider newProvider = new 
WriteAheadLocalStateProvider();
         final Map<PropertyDescriptor, PropertyValue> properties = new 
HashMap<>();
-        properties.put(WriteAheadLocalStateProvider.PATH, new 
StandardPropertyValue("target/local-state-provider/" + 
UUID.randomUUID().toString(), null, ParameterLookup.EMPTY));
+        properties.put(WriteAheadLocalStateProvider.PATH, new 
StandardPropertyValue(storageDirectory, null, ParameterLookup.EMPTY));
         properties.put(WriteAheadLocalStateProvider.ALWAYS_SYNC, new 
StandardPropertyValue("false", null, ParameterLookup.EMPTY));
         properties.put(WriteAheadLocalStateProvider.CHECKPOINT_INTERVAL, new 
StandardPropertyValue("2 mins", null, ParameterLookup.EMPTY));
         properties.put(WriteAheadLocalStateProvider.NUM_PARTITIONS, new 
StandardPropertyValue("16", null, ParameterLookup.EMPTY));
 
-        provider.initialize(new StateProviderInitializationContext() {
+        newProvider.initialize(new StateProviderInitializationContext() {
             @Override
             public String getIdentifier() {
                 return "Unit Test Provider Initialization Context";
@@ -90,19 +151,7 @@ public class TestWriteAheadLocalStateProvider extends 
AbstractTestStateProvider
                 return null;
             }
         });
-    }
 
-    @AfterEach
-    public void cleanup() throws IOException {
-        provider.onComponentRemoved(componentId);
-
-        if (wal != null) {
-            wal.shutdown();
-        }
-    }
-
-    @Override
-    protected StateProvider getProvider() {
-        return provider;
+        return newProvider;
     }
 }

Reply via email to