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

Abacn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 956fe0c90e4 [Flink] Bound SDF self-checkpoint residual state on the 
portable runner (#39191)
956fe0c90e4 is described below

commit 956fe0c90e41b0ca9ba9fc5af23b263e0a7f554f
Author: Elia Liu <[email protected]>
AuthorDate: Fri Jul 3 03:42:47 2026 +1000

    [Flink] Bound SDF self-checkpoint residual state on the portable runner 
(#39191)
    
    A splittable DoFn that self-checkpoints, for example a polling source using
    tracker.defer_remainder(), stored each residual under a new unique state 
tag,
    so every self-checkpoint registered another Flink keyed-state descriptor and
    the job leaked heap without bound.
    
    Store all residuals for a key and window under a single stable MapState 
keyed
    by the checkpoint id, and remove each entry once its timer fires and the
    residual is re-processed. The streaming and batch read paths look the 
residual
    up by timer id.
    
    Fixes #27648
---
 CHANGES.md                                         |   1 +
 .../FlinkStreamingPortablePipelineTranslator.java  |   1 -
 .../functions/FlinkExecutableStageFunction.java    |  19 ++--
 .../FlinkStreamingPortablePipelineTranslator.java  |   1 -
 .../functions/FlinkExecutableStageFunction.java    |  19 ++--
 .../streaming/ExecutableStageDoFnOperator.java     |  18 ++-
 .../control/BundleCheckpointHandlers.java          |  24 +++-
 .../control/BundleCheckpointHandlersTest.java      | 125 +++++++++++++++++++++
 8 files changed, 186 insertions(+), 22 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index e20194f9ab0..8bc74b958ba 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -98,6 +98,7 @@
 * Fixed GCS filesystem glob matching to correctly handle `/` in object names 
and support `**` for recursive matching (Go) 
([#38059](https://github.com/apache/beam/issues/38059)).
 * Fixed BigQueryEnrichmentHandler batch mode dropping earlier requests when 
multiple requests share the same enrichment key (Python) 
([#38035](https://github.com/apache/beam/issues/38035)).
 * Fixed IcebergIO writing manifest column bounds padded with trailing `0x00` 
bytes, which broke equality predicate pushdown in some query engines (Java) 
([#38580](https://github.com/apache/beam/issues/38580)).
+* Fixed unbounded checkpoint state growth for splittable DoFns that 
self-checkpoint on the portable Flink runner (Java) 
([#27648](https://github.com/apache/beam/issues/27648)).
 
 ## Security Fixes
 
diff --git 
a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java
 
b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java
index 647297394f5..efffa4fdbc4 100644
--- 
a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java
+++ 
b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java
@@ -733,7 +733,6 @@ public class FlinkStreamingPortablePipelineTranslator
 
   private <InputT, OutputT> void translateExecutableStage(
       String id, RunnerApi.Pipeline pipeline, StreamingTranslationContext 
context) {
-    // TODO: Fail on splittable DoFns.
     // TODO: Special-case single outputs to avoid multiplexing PCollections.
     RunnerApi.Components components = pipeline.getComponents();
     RunnerApi.PTransform transform = components.getTransformsOrThrow(id);
diff --git 
a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageFunction.java
 
b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageFunction.java
index 1298fd3105a..3f8b5bdfe47 100644
--- 
a/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageFunction.java
+++ 
b/runners/flink/2.0/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageFunction.java
@@ -31,7 +31,6 @@ import org.apache.beam.model.pipeline.v1.RunnerApi;
 import org.apache.beam.runners.core.InMemoryStateInternals;
 import org.apache.beam.runners.core.InMemoryTimerInternals;
 import org.apache.beam.runners.core.StateInternals;
-import org.apache.beam.runners.core.StateTags;
 import org.apache.beam.runners.core.TimerInternals;
 import org.apache.beam.runners.core.construction.SerializablePipelineOptions;
 import org.apache.beam.runners.flink.FlinkPipelineOptions;
@@ -56,6 +55,7 @@ import org.apache.beam.sdk.coders.Coder;
 import org.apache.beam.sdk.fn.data.FnDataReceiver;
 import org.apache.beam.sdk.io.FileSystems;
 import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.state.MapState;
 import org.apache.beam.sdk.transforms.join.RawUnionValue;
 import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
 import org.apache.beam.sdk.util.construction.PTransformTranslation;
@@ -284,12 +284,17 @@ public class FlinkExecutableStageFunction<InputT> extends 
AbstractRichFunction
           List<WindowedValue<InputT>> residuals = new ArrayList<>();
           TimerInternals.TimerData timer;
           while ((timer = sdfTimerInternals.removeNextProcessingTimer()) != 
null) {
-            WindowedValue stateValue =
-                sdfStateInternals
-                    .state(timer.getNamespace(), 
StateTags.value(timer.getTimerId(), inputCoder))
-                    .read();
-
-            residuals.add(stateValue);
+            MapState<String, WindowedValue<InputT>> residualState =
+                sdfStateInternals.state(
+                    timer.getNamespace(),
+                    
BundleCheckpointHandlers.StateAndTimerBundleCheckpointHandler.residualStateTag(
+                        inputCoder));
+            WindowedValue<InputT> stateValue = 
residualState.get(timer.getTimerId()).read();
+            residualState.remove(timer.getTimerId());
+            // A pre-#27648 savepoint has no entry under the new tag, so the 
read is null; drop it.
+            if (stateValue != null) {
+              residuals.add(stateValue);
+            }
           }
           processElements(residuals, bundle);
         }
diff --git 
a/runners/flink/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java
 
b/runners/flink/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java
index 77dc4d79516..e4abae4c751 100644
--- 
a/runners/flink/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java
+++ 
b/runners/flink/src/main/java/org/apache/beam/runners/flink/FlinkStreamingPortablePipelineTranslator.java
@@ -732,7 +732,6 @@ public class FlinkStreamingPortablePipelineTranslator
 
   private <InputT, OutputT> void translateExecutableStage(
       String id, RunnerApi.Pipeline pipeline, StreamingTranslationContext 
context) {
-    // TODO: Fail on splittable DoFns.
     // TODO: Special-case single outputs to avoid multiplexing PCollections.
     RunnerApi.Components components = pipeline.getComponents();
     RunnerApi.PTransform transform = components.getTransformsOrThrow(id);
diff --git 
a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageFunction.java
 
b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageFunction.java
index 6c13f8872e9..f513bda9935 100644
--- 
a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageFunction.java
+++ 
b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/functions/FlinkExecutableStageFunction.java
@@ -31,7 +31,6 @@ import org.apache.beam.model.pipeline.v1.RunnerApi;
 import org.apache.beam.runners.core.InMemoryStateInternals;
 import org.apache.beam.runners.core.InMemoryTimerInternals;
 import org.apache.beam.runners.core.StateInternals;
-import org.apache.beam.runners.core.StateTags;
 import org.apache.beam.runners.core.TimerInternals;
 import org.apache.beam.runners.core.construction.SerializablePipelineOptions;
 import org.apache.beam.runners.flink.FlinkPipelineOptions;
@@ -56,6 +55,7 @@ import org.apache.beam.sdk.coders.Coder;
 import org.apache.beam.sdk.fn.data.FnDataReceiver;
 import org.apache.beam.sdk.io.FileSystems;
 import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.state.MapState;
 import org.apache.beam.sdk.transforms.join.RawUnionValue;
 import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
 import org.apache.beam.sdk.util.construction.PTransformTranslation;
@@ -284,12 +284,17 @@ public class FlinkExecutableStageFunction<InputT> extends 
AbstractRichFunction
           List<WindowedValue<InputT>> residuals = new ArrayList<>();
           TimerInternals.TimerData timer;
           while ((timer = sdfTimerInternals.removeNextProcessingTimer()) != 
null) {
-            WindowedValue stateValue =
-                sdfStateInternals
-                    .state(timer.getNamespace(), 
StateTags.value(timer.getTimerId(), inputCoder))
-                    .read();
-
-            residuals.add(stateValue);
+            MapState<String, WindowedValue<InputT>> residualState =
+                sdfStateInternals.state(
+                    timer.getNamespace(),
+                    
BundleCheckpointHandlers.StateAndTimerBundleCheckpointHandler.residualStateTag(
+                        inputCoder));
+            WindowedValue<InputT> stateValue = 
residualState.get(timer.getTimerId()).read();
+            residualState.remove(timer.getTimerId());
+            // A pre-#27648 savepoint has no entry under the new tag, so the 
read is null; drop it.
+            if (stateValue != null) {
+              residuals.add(stateValue);
+            }
           }
           processElements(residuals, bundle);
         }
diff --git 
a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/ExecutableStageDoFnOperator.java
 
b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/ExecutableStageDoFnOperator.java
index f7bc62a8ec5..b4d5e43557c 100644
--- 
a/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/ExecutableStageDoFnOperator.java
+++ 
b/runners/flink/src/main/java/org/apache/beam/runners/flink/translation/wrappers/streaming/ExecutableStageDoFnOperator.java
@@ -88,6 +88,7 @@ import org.apache.beam.sdk.fn.data.FnDataReceiver;
 import org.apache.beam.sdk.function.ThrowingFunction;
 import org.apache.beam.sdk.options.PipelineOptions;
 import org.apache.beam.sdk.state.BagState;
+import org.apache.beam.sdk.state.MapState;
 import org.apache.beam.sdk.state.State;
 import org.apache.beam.sdk.state.StateContext;
 import org.apache.beam.sdk.state.TimeDomain;
@@ -1009,9 +1010,20 @@ public class ExecutableStageDoFnOperator<InputT, OutputT>
       Preconditions.checkNotNull(remoteBundle, "Call to onTimer outside of a 
bundle");
       if (StateAndTimerBundleCheckpointHandler.isSdfTimer(timerId)) {
         StateNamespace namespace = StateNamespaces.window(windowCoder, window);
-        WindowedValue stateValue =
-            keyedStateInternals.state(namespace, StateTags.value(timerId, 
residualCoder)).read();
-        processElement(stateValue);
+        // Residuals from pre-#27648 savepoints used per-residual ValueState 
tags and are not
+        // migrated; an older savepoint has no entry under the new tag, so the 
read is null and the
+        // residual is dropped.
+        MapState<String, WindowedValue<InputT>> residualState =
+            keyedStateInternals.state(
+                namespace, 
StateAndTimerBundleCheckpointHandler.residualStateTag(residualCoder));
+        WindowedValue<InputT> stateValue = residualState.get(timerId).read();
+        // Drop the consumed residual so a polling SDF's keyed state stays 
bounded across
+        // self-checkpoints (apache/beam#27648). Flink commits this removal 
only with the bundle's
+        // checkpoint, so on failure the timer re-fires and the residual is 
re-read.
+        residualState.remove(timerId);
+        if (stateValue != null) {
+          processElement(stateValue);
+        }
       } else {
         KV<String, String> transformAndTimerFamilyId =
             TimerReceiverFactory.decodeTimerDataTimerId(timerFamilyId);
diff --git 
a/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/control/BundleCheckpointHandlers.java
 
b/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/control/BundleCheckpointHandlers.java
index 34d952b1560..f869b047f33 100644
--- 
a/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/control/BundleCheckpointHandlers.java
+++ 
b/runners/java-fn-execution/src/main/java/org/apache/beam/runners/fnexecution/control/BundleCheckpointHandlers.java
@@ -23,12 +23,15 @@ import org.apache.beam.runners.core.StateInternals;
 import org.apache.beam.runners.core.StateInternalsFactory;
 import org.apache.beam.runners.core.StateNamespace;
 import org.apache.beam.runners.core.StateNamespaces;
+import org.apache.beam.runners.core.StateTag;
 import org.apache.beam.runners.core.StateTags;
 import org.apache.beam.runners.core.TimerInternals;
 import org.apache.beam.runners.core.TimerInternalsFactory;
 import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.coders.StringUtf8Coder;
 import org.apache.beam.sdk.fn.IdGenerator;
 import org.apache.beam.sdk.fn.IdGenerators;
+import org.apache.beam.sdk.state.MapState;
 import org.apache.beam.sdk.state.TimeDomain;
 import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
 import org.apache.beam.sdk.util.CoderUtils;
@@ -48,7 +51,7 @@ public class BundleCheckpointHandlers {
   /**
    * A {@link BundleCheckpointHandler} which uses {@link
    * org.apache.beam.runners.core.TimerInternals.TimerData} and {@link
-   * org.apache.beam.sdk.state.ValueState} to reschedule {@link 
DelayedBundleApplication}.
+   * org.apache.beam.sdk.state.MapState} to reschedule {@link 
DelayedBundleApplication}.
    */
   public static class StateAndTimerBundleCheckpointHandler<T> implements 
BundleCheckpointHandler {
 
@@ -82,6 +85,20 @@ public class BundleCheckpointHandlers {
       return SDF_PREFIX + ":" + id + ":" + index;
     }
 
+    /** The state id under which all SDF self-checkpoint residuals for a 
key/window are stored. */
+    public static final String SDF_RESIDUAL_STATE = "sdf_checkpoint_residuals";
+
+    /**
+     * The single, stable state tag holding every SDF self-checkpoint residual 
for a key/window,
+     * keyed by the per-residual checkpoint id. Storing all residuals under 
one stable descriptor,
+     * instead of a fresh {@link StateTags#value} per residual, keeps a 
polling SDF's keyed state
+     * bounded. See https://github.com/apache/beam/issues/27648.
+     */
+    public static <T> StateTag<MapState<String, WindowedValue<T>>> 
residualStateTag(
+        Coder<WindowedValue<T>> residualCoder) {
+      return StateTags.map(SDF_RESIDUAL_STATE, StringUtf8Coder.of(), 
residualCoder);
+    }
+
     @Override
     public void onCheckpoint(ProcessBundleResponse response) {
       String id = idGenerator.getId();
@@ -126,8 +143,9 @@ public class BundleCheckpointHandlers {
                 Instant.ofEpochMilli(outputTimestamp),
                 TimeDomain.PROCESSING_TIME);
             stateInternals
-                .state(stateNamespace, StateTags.value(tag, residualCoder))
-                .write(
+                .state(stateNamespace, residualStateTag(residualCoder))
+                .put(
+                    tag,
                     WindowedValues.of(
                         stateValue.getValue(),
                         stateValue.getTimestamp(),
diff --git 
a/runners/java-fn-execution/src/test/java/org/apache/beam/runners/fnexecution/control/BundleCheckpointHandlersTest.java
 
b/runners/java-fn-execution/src/test/java/org/apache/beam/runners/fnexecution/control/BundleCheckpointHandlersTest.java
new file mode 100644
index 00000000000..24714dc498d
--- /dev/null
+++ 
b/runners/java-fn-execution/src/test/java/org/apache/beam/runners/fnexecution/control/BundleCheckpointHandlersTest.java
@@ -0,0 +1,125 @@
+/*
+ * 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.beam.runners.fnexecution.control;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasSize;
+
+import java.util.HashSet;
+import java.util.Set;
+import org.apache.beam.model.fnexecution.v1.BeamFnApi.BundleApplication;
+import org.apache.beam.model.fnexecution.v1.BeamFnApi.DelayedBundleApplication;
+import org.apache.beam.model.fnexecution.v1.BeamFnApi.ProcessBundleResponse;
+import org.apache.beam.runners.core.InMemoryStateInternals;
+import org.apache.beam.runners.core.InMemoryTimerInternals;
+import org.apache.beam.runners.core.StateInternals;
+import org.apache.beam.runners.core.StateInternalsFactory;
+import org.apache.beam.runners.core.StateNamespace;
+import org.apache.beam.runners.core.StateTag;
+import org.apache.beam.runners.core.TimerInternalsFactory;
+import 
org.apache.beam.runners.fnexecution.control.BundleCheckpointHandlers.StateAndTimerBundleCheckpointHandler;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.coders.StringUtf8Coder;
+import org.apache.beam.sdk.state.State;
+import org.apache.beam.sdk.state.StateContext;
+import org.apache.beam.sdk.transforms.windowing.GlobalWindow;
+import org.apache.beam.sdk.util.CoderUtils;
+import org.apache.beam.sdk.values.WindowedValue;
+import org.apache.beam.sdk.values.WindowedValues;
+import org.apache.beam.vendor.grpc.v1p69p0.com.google.protobuf.ByteString;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link BundleCheckpointHandlers}. */
+@RunWith(JUnit4.class)
+public class BundleCheckpointHandlersTest {
+
+  private static final Coder<WindowedValue<String>> RESIDUAL_CODER =
+      WindowedValues.getFullCoder(StringUtf8Coder.of(), 
GlobalWindow.Coder.INSTANCE);
+
+  /**
+   * Regression test for https://github.com/apache/beam/issues/27648.
+   *
+   * <p>A polling SDF self-checkpoints on every poll via {@code 
tracker.defer_remainder()}. Each
+   * self-checkpoint used to store its residual under a brand new, unique 
state tag
+   * ("sdf_checkpoint:&lt;id&gt;:&lt;index&gt;"), so every poll registered 
another keyed-state
+   * descriptor and the job leaked heap without bound. All residuals for a 
key/window must instead
+   * live under a single, stable state descriptor.
+   */
+  @Test
+  public void repeatedSelfCheckpointsUseBoundedStateDescriptors() throws 
Exception {
+    Set<String> stateDescriptorIds = new HashSet<>();
+    StateInternalsFactory<String> stateInternalsFactory =
+        key -> new RecordingStateInternals(InMemoryStateInternals.forKey(key), 
stateDescriptorIds);
+    TimerInternalsFactory<String> timerInternalsFactory = key -> new 
InMemoryTimerInternals();
+
+    StateAndTimerBundleCheckpointHandler<String> handler =
+        new StateAndTimerBundleCheckpointHandler<>(
+            timerInternalsFactory,
+            stateInternalsFactory,
+            RESIDUAL_CODER,
+            GlobalWindow.Coder.INSTANCE);
+
+    int selfCheckpoints = 100;
+    for (int i = 0; i < selfCheckpoints; i++) {
+      handler.onCheckpoint(residualResponse("key"));
+    }
+
+    // One stable descriptor for all residuals, no matter how many times the 
SDF checkpointed.
+    assertThat(stateDescriptorIds, hasSize(1));
+  }
+
+  private static ProcessBundleResponse residualResponse(String key) throws 
Exception {
+    // The residual element's value is used as the SDF key.
+    byte[] encodedElement =
+        CoderUtils.encodeToByteArray(RESIDUAL_CODER, 
WindowedValues.valueInGlobalWindow(key));
+    return ProcessBundleResponse.newBuilder()
+        .addResidualRoots(
+            DelayedBundleApplication.newBuilder()
+                .setApplication(
+                    BundleApplication.newBuilder()
+                        .setElement(ByteString.copyFrom(encodedElement))
+                        .build())
+                .build())
+        .build();
+  }
+
+  /** A {@link StateInternals} that records every state descriptor id it is 
asked for. */
+  private static class RecordingStateInternals implements StateInternals {
+    private final StateInternals delegate;
+    private final Set<String> descriptorIds;
+
+    RecordingStateInternals(StateInternals delegate, Set<String> 
descriptorIds) {
+      this.delegate = delegate;
+      this.descriptorIds = descriptorIds;
+    }
+
+    @Override
+    public Object getKey() {
+      return delegate.getKey();
+    }
+
+    @Override
+    public <T extends State> T state(
+        StateNamespace namespace, StateTag<T> address, StateContext<?> c) {
+      descriptorIds.add(address.getId());
+      return delegate.state(namespace, address, c);
+    }
+  }
+}

Reply via email to