This is an automated email from the ASF dual-hosted git repository.
1996fanrui pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink.git
The following commit(s) were added to refs/heads/master by this push:
new 9bc156b7faf [FLINK-39909][state] Fix heap state backend savepoint NPE
with null map state values
9bc156b7faf is described below
commit 9bc156b7faf811ca6940a05e02df900ab9bf6627
Author: Mukul Gupta <[email protected]>
AuthorDate: Thu Jun 11 06:49:36 2026 +0000
[FLINK-39909][state] Fix heap state backend savepoint NPE with null map
state values
---
.../state/heap/HeapKeyValueStateIterator.java | 4 +-
.../MapStateNullValueCheckpointingITCase.java | 139 +++++++++++++++++++++
2 files changed, 142 insertions(+), 1 deletion(-)
diff --git
a/flink-runtime/src/main/java/org/apache/flink/runtime/state/heap/HeapKeyValueStateIterator.java
b/flink-runtime/src/main/java/org/apache/flink/runtime/state/heap/HeapKeyValueStateIterator.java
index 80338a66dd3..b525e5f1e3e 100644
---
a/flink-runtime/src/main/java/org/apache/flink/runtime/state/heap/HeapKeyValueStateIterator.java
+++
b/flink-runtime/src/main/java/org/apache/flink/runtime/state/heap/HeapKeyValueStateIterator.java
@@ -362,7 +362,9 @@ public final class HeapKeyValueStateIterator implements
KeyValueStateIterator {
compositeKeyBuilder.buildCompositeKeyUserKey(entry.getKey(), userKeySerializer);
Object userValue = entry.getValue();
valueOut.writeBoolean(userValue == null);
- userValueSerializer.serialize(userValue, valueOut);
+ if (userValue != null) {
+ userValueSerializer.serialize(userValue, valueOut);
+ }
currentValue = valueOut.getCopyOfBuffer();
if (!mapEntries.hasNext()) {
diff --git
a/flink-tests/src/test/java/org/apache/flink/test/checkpointing/MapStateNullValueCheckpointingITCase.java
b/flink-tests/src/test/java/org/apache/flink/test/checkpointing/MapStateNullValueCheckpointingITCase.java
index f5237534c86..a9ba3070806 100644
---
a/flink-tests/src/test/java/org/apache/flink/test/checkpointing/MapStateNullValueCheckpointingITCase.java
+++
b/flink-tests/src/test/java/org/apache/flink/test/checkpointing/MapStateNullValueCheckpointingITCase.java
@@ -114,6 +114,8 @@ class MapStateNullValueCheckpointingITCase {
StatefulMapper.firstRunFuture = new CompletableFuture<>();
StatefulMapper.secondRunFuture = new CompletableFuture<>();
+ NullUnsafeStatefulMapper.firstRunFuture = new CompletableFuture<>();
+ NullUnsafeStatefulMapper.secondRunFuture = new CompletableFuture<>();
}
@AfterEach
@@ -218,6 +220,143 @@ class MapStateNullValueCheckpointingITCase {
assertThat(restoredState).containsKey("null-key");
}
+ /**
+ * Tests that MapState with null values works correctly with null-unsafe
serializers (e.g.,
+ * IntSerializer) during checkpoint/savepoint and restore. This verifies
the fix in {@link
+ * org.apache.flink.runtime.state.heap.HeapKeyValueStateIterator} which
previously would NPE
+ * when serializing null values during savepoint.
+ */
+ @TestTemplate
+ void testMapStateWithNullUnsafeSerializerCheckpointingAndRestore() throws
Exception {
+ final String savepointPath = runJobWithNullUnsafeSerializer();
+ assertThat(savepointPath).isNotEmpty();
+ restoreAndVerifyNullUnsafeSerializer(savepointPath);
+ }
+
+ private String runJobWithNullUnsafeSerializer() throws Exception {
+ Configuration conf = new Configuration();
+ conf.set(
+ CheckpointingOptions.CHECKPOINTS_DIRECTORY,
+ TempDirUtils.newFolder(tmpFolder).toURI().toString());
+ conf.set(CheckpointingOptions.EXTERNALIZED_CHECKPOINT_RETENTION,
RETAIN_ON_CANCELLATION);
+ conf.set(
+ CheckpointingOptions.SAVEPOINT_DIRECTORY,
+ TempDirUtils.newFolder(tmpFolder).toURI().toString());
+ conf.set(StateBackendOptions.STATE_BACKEND, stateBackend);
+ StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment(conf);
+ env.setParallelism(1);
+
+ env.fromSource(createSource(), WatermarkStrategy.noWatermarks(), "Data
Generator Source")
+ .keyBy(v -> 0)
+ .map(new NullUnsafeStatefulMapper(true))
+ .sinkTo(new DiscardingSink<>());
+
+ JobGraph jobGraph = env.getStreamGraph().getJobGraph();
+ MiniCluster miniCluster = cluster.getMiniCluster();
+ miniCluster.submitJob(jobGraph).get();
+
+ JobID jobID = jobGraph.getJobID();
+ NullUnsafeStatefulMapper.firstRunFuture.get(2, TimeUnit.MINUTES);
+
+ if (snapshotType.isLeft()) {
+ cluster.getClusterClient()
+ .triggerCheckpoint(jobID, snapshotType.left())
+ .get(2, TimeUnit.MINUTES);
+ String checkpointPath =
+ CommonTestUtils.getLatestCompletedCheckpointPath(jobID,
miniCluster)
+ .<NoSuchElementException>orElseThrow(
+ () ->
+ new NoSuchElementException(
+ "No checkpoint was created
yet"));
+ cluster.getClusterClient().cancel(jobID);
+ return checkpointPath;
+ } else {
+ return cluster.getClusterClient()
+ .stopWithSavepoint(jobID, false, null,
snapshotType.right())
+ .get(2, TimeUnit.MINUTES);
+ }
+ }
+
+ private void restoreAndVerifyNullUnsafeSerializer(String savepointPath)
throws Exception {
+ Configuration conf = new Configuration();
+ conf.set(
+ CheckpointingOptions.CHECKPOINTS_DIRECTORY,
+ TempDirUtils.newFolder(tmpFolder).toURI().toString());
+ conf.set(
+ CheckpointingOptions.SAVEPOINT_DIRECTORY,
+ TempDirUtils.newFolder(tmpFolder).toURI().toString());
+ conf.set(StateBackendOptions.STATE_BACKEND, stateBackend);
+ StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment(conf);
+ env.setParallelism(1);
+
+ env.fromSource(createSource(), WatermarkStrategy.noWatermarks(), "Data
Generator Source")
+ .keyBy(v -> 0)
+ .map(new NullUnsafeStatefulMapper(false))
+ .sinkTo(new DiscardingSink<>());
+
+ JobGraph jobGraph = env.getStreamGraph().getJobGraph();
+
jobGraph.setSavepointRestoreSettings(SavepointRestoreSettings.forPath(savepointPath));
+
+ MiniCluster miniCluster = cluster.getMiniCluster();
+ miniCluster.submitJob(jobGraph).get();
+
+ Map<String, Integer> restoredState =
+ NullUnsafeStatefulMapper.secondRunFuture.get(2,
TimeUnit.MINUTES);
+
+ assertThat(restoredState.get("key")).isEqualTo(42);
+ assertThat(restoredState.get("null-key")).isNull();
+ assertThat(restoredState).containsKey("null-key");
+ }
+
+ /**
+ * A stateful mapper using IntSerializer (null-unsafe) for the map state
value type. This
+ * exercises the code path where serializers that cannot handle null will
fail during
+ * savepoint/checkpoint if the null-handling logic is incorrect.
+ */
+ private static class NullUnsafeStatefulMapper extends
RichMapFunction<Long, Long> {
+
+ static CompletableFuture<Void> firstRunFuture;
+ static CompletableFuture<Map<String, Integer>> secondRunFuture;
+
+ private final boolean isFirstRun;
+ private boolean hasPopulated;
+ private transient MapState<String, Integer> mapState;
+
+ NullUnsafeStatefulMapper(boolean isFirstRun) {
+ this.isFirstRun = isFirstRun;
+ }
+
+ @Override
+ public void open(OpenContext context) {
+ MapStateDescriptor<String, Integer> mapStateDescriptor =
+ new MapStateDescriptor<>(
+ "map-state-int",
+ BasicTypeInfo.STRING_TYPE_INFO,
+ BasicTypeInfo.INT_TYPE_INFO);
+ mapState = getRuntimeContext().getMapState(mapStateDescriptor);
+ hasPopulated = false;
+ }
+
+ @Override
+ public Long map(Long value) throws Exception {
+ if (hasPopulated) {
+ return value;
+ }
+ if (isFirstRun) {
+ mapState.put("key", 42);
+ mapState.put("null-key", null);
+ firstRunFuture.complete(null);
+ } else {
+ Map<String, Integer> restoredState = new HashMap<>();
+ restoredState.put("key", mapState.get("key"));
+ restoredState.put("null-key", mapState.get("null-key"));
+ secondRunFuture.complete(restoredState);
+ }
+ hasPopulated = true;
+ return value;
+ }
+ }
+
private static class StatefulMapper extends RichMapFunction<Long, Long> {
static CompletableFuture<Void> firstRunFuture;