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

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 444e9790 [runtime][java] Preserve pending Fluss action state across 
checkpoints (#1175)
444e9790 is described below

commit 444e9790b33b5bc12d3eb523b1d56049b40d4ba5
Author: rob-9 <[email protected]>
AuthorDate: Fri Oct 16 01:31:58 2026 -0700

    [runtime][java] Preserve pending Fluss action state across checkpoints 
(#1175)
---
 docs/content/docs/operations/configuration.md      |   4 +
 .../runtime/actionstate/FlussActionStateStore.java |  67 +++-
 .../runtime/operator/ActionExecutionOperator.java  |   5 +-
 .../runtime/operator/DurableExecutionManager.java  |  79 ++---
 .../FlussActionStateStoreIntegrationTest.java      |   8 +-
 .../FlussActionStateStoreSaslIntegrationTest.java  |  21 +-
 .../actionstate/FlussActionStateStoreTest.java     | 182 ++++++++++-
 .../operator/DurableExecutionManagerTest.java      | 106 ++++--
 .../FlussActionStateRecoveryIntegrationTest.java   | 354 +++++++++++++++++++++
 9 files changed, 728 insertions(+), 98 deletions(-)

diff --git a/docs/content/docs/operations/configuration.md 
b/docs/content/docs/operations/configuration.md
index a934ca92..603b2e31 100644
--- a/docs/content/docs/operations/configuration.md
+++ b/docs/content/docs/operations/configuration.md
@@ -345,6 +345,10 @@ To migrate a job that previously emitted tombstones, first 
stop it cleanly so it
 
 Here are the configuration options for Fluss-based Action State Store.
 
+When taking a checkpoint, the store captures the bucket end offsets and then 
synchronously appends a fresh copy of each cached action state needed for 
replay before saving that recovery marker. States whose input sequence is at or 
below the key’s last completed sequence number in this checkpoint are skipped. 
Completed actions in unfinished inputs and all states for keys without a 
completed sequence number are still refreshed. This keeps durable call results 
available even when they were  [...]
+
+Fluss log retention must preserve the records from every checkpoint or 
savepoint you intend to restore. Refreshing state does not protect older 
restore points from retention or repair recovery markers created before this 
fix.
+
 | Key                          | Default          | Type    | Description      
                                                                        |
 
|------------------------------|------------------|---------|------------------------------------------------------------------------------------------|
 | `flussBootstrapServers`      | "localhost:9123" | String  | The Fluss 
bootstrap servers address.                                                     |
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStore.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStore.java
index 04a3f654..001908da 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStore.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStore.java
@@ -214,6 +214,14 @@ public class FlussActionStateStore implements 
ActionStateStore {
             throws Exception {
         String stateKey = keyEncoder.generateKey(key, seqNum, action, event);
         String businessKeyIdentity = 
keyEncoder.generateBusinessKeyIdentity(key);
+        appendState(stateKey, businessKeyIdentity, state);
+        actionStates.put(stateKey, state);
+
+        LOG.debug("Stored action state: key={}, isCompleted={}", stateKey, 
state.isCompleted());
+    }
+
+    private void appendState(String stateKey, String businessKeyIdentity, 
ActionState state)
+            throws Exception {
         byte[] payload = ActionStateSerde.serialize(state);
 
         GenericRow row =
@@ -227,10 +235,12 @@ public class FlussActionStateStore implements 
ActionStateStore {
         //  (see
         // 
https://github.com/apache/fluss/blob/5850c837/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java#L234-L241).
         //  Note: steps affecting recovery correctness must remain synchronous.
-        writer.append(row).get();
-        actionStates.put(stateKey, state);
-
-        LOG.debug("Stored action state: key={}, isCompleted={}", stateKey, 
state.isCompleted());
+        try {
+            writer.append(row).get();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw e;
+        }
     }
 
     @Override
@@ -483,19 +493,58 @@ public class FlussActionStateStore implements 
ActionStateStore {
                 buckets.add(b);
             }
             return admin.listOffsets(tablePath, buckets, 
offsetSpec).all().get();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(
+                    "Interrupted getting offsets for Fluss table: " + 
tablePath, e);
         } catch (Exception e) {
             throw new RuntimeException("Failed to get offsets for Fluss table: 
" + tablePath, e);
         }
     }
 
+    @Override
+    public Object getRecoveryMarker() {
+        return getRecoveryMarker(Map.of());
+    }
+
     /**
-     * Returns the end offsets of each bucket as a recovery marker. Similar to 
Kafka's
-     * implementation, this captures the current log position so that {@link 
#rebuildState} can
-     * resume from these offsets instead of scanning from the beginning.
+     * Captures bucket end offsets, then synchronously appends cached states 
needed for replay
+     * before returning the recovery marker.
+     *
+     * <p>A pending action may already have persisted call results before the 
checkpoint. Starting
+     * recovery at the unmodified log end would lose those results. Fluss 
append acknowledgements do
+     * not expose record offsets, so refreshing the cache into the recovery 
window avoids guessing
+     * where the earlier writes landed. States at or below the key's completed 
input sequence in
+     * this checkpoint are skipped. Completed actions in unfinished inputs and 
all states for keys
+     * without a completed sequence number are still refreshed.
+     *
+     * <p>Called on the operator thread, serially with puts and pruning. A 
failed refresh fails the
+     * checkpoint; partial appends remain safe for earlier recovery markers. 
Skipped states stay in
+     * the cache until checkpoint completion prunes them. This adds one append 
per state needed for
+     * replay and requires Fluss retention to preserve the recovery window.
      */
     @Override
-    public Object getRecoveryMarker() {
-        return getBucketEndOffsets();
+    public Object getRecoveryMarker(Map<?, Long> lastCompletedSequenceNumbers) 
{
+        Map<String, Long> completedSequencesByIdentity = new HashMap<>();
+        lastCompletedSequenceNumbers.forEach(
+                (key, sequenceNumber) ->
+                        completedSequencesByIdentity.put(
+                                keyEncoder.generateBusinessKeyIdentity(key), 
sequenceNumber));
+        Map<Integer, Long> offsets = getBucketEndOffsets();
+        try {
+            for (Map.Entry<String, ActionState> entry : 
actionStates.entrySet()) {
+                List<String> keyParts = 
ActionStateUtil.parseKey(entry.getKey());
+                String identity = keyParts.get(4);
+                Long completedSequenceNumber = 
completedSequencesByIdentity.get(identity);
+                if (completedSequenceNumber == null
+                        || Long.parseLong(keyParts.get(1)) > 
completedSequenceNumber) {
+                    appendState(entry.getKey(), identity, entry.getValue());
+                }
+            }
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to refresh Fluss action state 
for checkpoint", e);
+        }
+        return offsets;
     }
 
     /**
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
index 92a218bf..68a85ee2 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/ActionExecutionOperator.java
@@ -1206,10 +1206,7 @@ public class ActionExecutionOperator<IN, OUT> extends 
AbstractStreamOperator<OUT
     }
 
     private void snapshotStateInternal(StateSnapshotContext context) throws 
Exception {
-        Map<Object, Long> lastCompletedSequenceNumbers =
-                durableExecManager.snapshotLastCompletedSequenceNumbers(
-                        getKeyedStateBackend(), context.getCheckpointId());
-        
durableExecManager.snapshotRecoveryMarker(lastCompletedSequenceNumbers);
+        durableExecManager.snapshotState(getKeyedStateBackend(), 
context.getCheckpointId());
 
         super.snapshotState(context);
     }
diff --git 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/DurableExecutionManager.java
 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/DurableExecutionManager.java
index 24cb9c86..d84bfc45 100644
--- 
a/runtime/src/main/java/org/apache/flink/agents/runtime/operator/DurableExecutionManager.java
+++ 
b/runtime/src/main/java/org/apache/flink/agents/runtime/operator/DurableExecutionManager.java
@@ -155,10 +155,10 @@ class DurableExecutionManager implements 
ActionStatePersister, AutoCloseable {
      * Registers the per-key {@code lastCompletedSequenceNumber} value state.
      *
      * <p>This state tracks, per key, the latest message sequence number whose 
action chain finished
-     * processing. It is used by {@link #snapshotLastCompletedSequenceNumbers} 
so that {@link
-     * #notifyCheckpointComplete} can prune durable action state strictly up 
to the sequence number
-     * that was committed by the corresponding checkpoint. Called from the 
operator's {@code open()}
-     * after keyed state is available. No-op when durable execution is 
disabled.
+     * processing. It is used by {@link #snapshotState} so that {@link 
#notifyCheckpointComplete}
+     * can prune durable action state strictly up to the sequence number that 
was committed by the
+     * corresponding checkpoint. Called from the operator's {@code open()} 
after keyed state is
+     * available. No-op when durable execution is disabled.
      *
      * @param runtimeContext the operator's runtime context, used to obtain 
the value state handle.
      */
@@ -175,9 +175,8 @@ class DurableExecutionManager implements 
ActionStatePersister, AutoCloseable {
      * Records the sequence number of the most recently completed input event 
for the current key.
      *
      * <p>Must be called under a keyed context after all action tasks 
generated by an input event
-     * have finished. The recorded value is later picked up by {@link
-     * #snapshotLastCompletedSequenceNumbers} during checkpoint snapshotting. 
No-op when durable
-     * execution is disabled.
+     * have finished. The recorded value is later picked up by {@link 
#snapshotState} during
+     * checkpoint snapshotting. No-op when durable execution is disabled.
      *
      * @param sequenceNum the per-key sequence number that just finished 
processing.
      */
@@ -346,17 +345,16 @@ class DurableExecutionManager implements 
ActionStatePersister, AutoCloseable {
      * given checkpoint.
      *
      * <p>The mapping from checkpoint id to per-key sequence numbers must have 
been recorded earlier
-     * via {@link #snapshotLastCompletedSequenceNumbers}. After pruning, the 
entry for that
-     * checkpoint is removed. No-op when durable execution is disabled.
+     * via {@link #snapshotState}. After pruning, the entry for that 
checkpoint is removed. No-op
+     * when durable execution is disabled.
      *
      * <p><b>Invariant:</b> the {@code checkpointIdToSeqNums.remove} below, 
the {@code put} in
-     * {@link #snapshotLastCompletedSequenceNumbers}, and the {@code remove} 
in {@link
-     * #notifyCheckpointAborted} MUST all share the same {@code 
actionStateStore != null} guard.
-     * Every snapshotted entry is released by exactly one of the two paths — 
Flink notifies either
-     * {@code notifyCheckpointComplete} OR {@code notifyCheckpointAborted} for 
each checkpoint,
-     * never both. Dropping the guard on any side breaks the symmetry and 
reintroduces the
-     * unbounded-map leak tracked by <a 
href="https://github.com/apache/flink-agents/issues/645";>
-     * issue #645</a> (complete path) or <a
+     * {@link #snapshotState}, and the {@code remove} in {@link 
#notifyCheckpointAborted} MUST all
+     * share the same {@code actionStateStore != null} guard. Every 
snapshotted entry is released by
+     * exactly one of the two paths — Flink notifies either {@code 
notifyCheckpointComplete} OR
+     * {@code notifyCheckpointAborted} for each checkpoint, never both. 
Dropping the guard on any
+     * side breaks the symmetry and reintroduces the unbounded-map leak 
tracked by <a
+     * href="https://github.com/apache/flink-agents/issues/645";>issue #645</a> 
(complete path) or <a
      * href="https://github.com/apache/flink-agents/issues/665";>issue #665</a> 
(abort path).
      *
      * @param checkpointId the id of the completed checkpoint.
@@ -373,20 +371,20 @@ class DurableExecutionManager implements 
ActionStatePersister, AutoCloseable {
     }
 
     /**
-     * Releases the per-checkpoint sequence-number snapshot recorded by {@link
-     * #snapshotLastCompletedSequenceNumbers} when Flink aborts the checkpoint 
instead of completing
-     * it. Unlike {@link #notifyCheckpointComplete}, this method does NOT 
prune durable action
-     * state: the aborted checkpoint's writes were never committed, so the 
previously-pruned-up-to
-     * point is still the {@code lastCompletedSequenceNumber} from the last 
successful checkpoint,
-     * and any state recorded since is still load-bearing for recovery from 
that prior checkpoint.
-     * We only release the in-memory tracking entry to prevent unbounded 
growth of {@code
-     * checkpointIdToSeqNums} when checkpoints abort under sustained pressure 
(issue #665).
+     * Releases the per-checkpoint sequence-number snapshot recorded by {@link 
#snapshotState} when
+     * Flink aborts the checkpoint instead of completing it. Unlike {@link
+     * #notifyCheckpointComplete}, this method does NOT prune durable action 
state: the aborted
+     * checkpoint's writes were never committed, so the 
previously-pruned-up-to point is still the
+     * {@code lastCompletedSequenceNumber} from the last successful 
checkpoint, and any state
+     * recorded since is still load-bearing for recovery from that prior 
checkpoint. We only release
+     * the in-memory tracking entry to prevent unbounded growth of {@code 
checkpointIdToSeqNums}
+     * when checkpoints abort under sustained pressure (issue #665).
      *
      * <p>Safe when no entry exists for {@code checkpointId} (e.g., abort 
fires for a checkpoint
      * this task never snapshotted): {@link Map#remove} returns {@code null}. 
No-op when durable
      * execution is disabled.
      *
-     * <p><b>Invariant:</b> see {@link #snapshotLastCompletedSequenceNumbers} 
— together with {@link
+     * <p><b>Invariant:</b> see {@link #snapshotState} — together with {@link
      * #notifyCheckpointComplete}, this method shares the same {@code 
actionStateStore != null}
      * guard that releases entries recorded by the snapshot side. Dropping the 
guard on any side
      * breaks the symmetry and reintroduces the unbounded-map leak tracked by 
<a
@@ -401,25 +399,17 @@ class DurableExecutionManager implements 
ActionStatePersister, AutoCloseable {
         }
     }
 
-    void snapshotRecoveryMarker(Map<Object, Long> 
lastCompletedSequenceNumbers) throws Exception {
-        if (actionStateStore != null) {
-            Object recoveryMarker =
-                    
actionStateStore.getRecoveryMarker(lastCompletedSequenceNumbers);
-            if (recoveryMarker != null) {
-                recoveryMarkerOpState.update(List.of(recoveryMarker));
-            }
-        }
-    }
-
     /**
      * Captures the per-key {@code lastCompletedSequenceNumber} across all 
keys and associates the
-     * snapshot with the given checkpoint id.
+     * snapshot with the given checkpoint id after the recovery marker has 
been saved.
      *
      * <p>Called from the operator's {@code snapshotState}. Keys whose state 
has no recorded value
      * are skipped (this happens for keys that produced no completed action 
chain yet). The recorded
-     * mapping is consulted later by {@link #notifyCheckpointComplete(long)} 
to prune durable state
-     * strictly up to the sequence number that was committed by that 
checkpoint. No-op when durable
-     * execution is disabled.
+     * boundaries are passed to the store before capturing the recovery 
marker. Only after that
+     * succeeds is the mapping registered for pruning, so failed snapshots 
leave no tracking entry.
+     * The mapping is consulted later by {@link 
#notifyCheckpointComplete(long)} to prune durable
+     * state strictly up to the sequence number that was committed by that 
checkpoint. No-op when
+     * durable execution is disabled.
      *
      * <p><b>Invariant:</b> the {@code checkpointIdToSeqNums.put} below, the 
{@code remove} in
      * {@link #notifyCheckpointComplete(long)}, and the {@code remove} in 
{@link
@@ -431,13 +421,11 @@ class DurableExecutionManager implements 
ActionStatePersister, AutoCloseable {
      *
      * @param keyedStateBackend the keyed state backend to scan.
      * @param checkpointId the id of the checkpoint being snapshotted.
-     * @return the completed sequence numbers captured for the checkpoint.
      */
     @SuppressWarnings("unchecked")
-    Map<Object, Long> snapshotLastCompletedSequenceNumbers(
-            KeyedStateBackend<?> keyedStateBackend, long checkpointId) throws 
Exception {
+    void snapshotState(KeyedStateBackend<?> keyedStateBackend, long 
checkpointId) throws Exception {
         if (actionStateStore == null) {
-            return Map.of();
+            return;
         }
         HashMap<Object, Long> keyToSeqNum = new HashMap<>();
         ((KeyedStateBackend<Object>) keyedStateBackend)
@@ -452,8 +440,11 @@ class DurableExecutionManager implements 
ActionStatePersister, AutoCloseable {
                                 keyToSeqNum.put(key, completedSequenceNumber);
                             }
                         });
+        Object recoveryMarker = 
actionStateStore.getRecoveryMarker(keyToSeqNum);
+        if (recoveryMarker != null) {
+            recoveryMarkerOpState.update(List.of(recoveryMarker));
+        }
         checkpointIdToSeqNums.put(checkpointId, keyToSeqNum);
-        return keyToSeqNum;
     }
 
     // --- Durable execution context map accessors ---
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreIntegrationTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreIntegrationTest.java
index 29c532e5..1201ed44 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreIntegrationTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreIntegrationTest.java
@@ -208,8 +208,8 @@ public class FlussActionStateStoreIntegrationTest {
             // that removes entries with higher seqNums (same as Kafka backend 
behavior).
             // Data written after the marker should be recovered
             assertThat(recoveredStore.get(TEST_KEY, 2L, testAction, 
testEvent)).isNotNull();
-            // Data written before the marker should NOT be in the rebuilt 
cache
-            assertThat(recoveredStore.get(TEST_KEY, 1L, testAction, 
testEvent)).isNull();
+            // A still-cached pending state is refreshed into the checkpoint's 
replay window.
+            assertThat(recoveredStore.get(TEST_KEY, 1L, testAction, 
testEvent)).isNotNull();
         } finally {
             recoveredStore.close();
             // Prevent double-close in tearDown
@@ -372,8 +372,8 @@ public class FlussActionStateStoreIntegrationTest {
             try {
                 recoveredStore.rebuildState(List.of(marker));
 
-                // Data written after marker should be recovered
-                for (int i = 10; i < 15; i++) {
+                // Both refreshed pre-checkpoint states and later writes must 
be recovered.
+                for (int i = 0; i < 15; i++) {
                     String key = "multi-key-" + i;
                     assertThat(recoveredStore.get(key, 1L, action1, 
testEvent)).isNotNull();
                 }
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreSaslIntegrationTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreSaslIntegrationTest.java
index 68d049a0..8b907841 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreSaslIntegrationTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreSaslIntegrationTest.java
@@ -94,8 +94,10 @@ public class FlussActionStateStoreSaslIntegrationTest {
         Action testAction = new NoOpAction("sasl-recovery-action");
         InputEvent testEvent = new InputEvent("sasl-recovery-data");
 
-        // Write data and capture recovery marker
-        store.put(TEST_KEY, 1L, testAction, testEvent, new 
ActionState(testEvent));
+        // A durable call has completed, but its enclosing action is still 
pending at checkpoint.
+        ActionState pending = new ActionState(testEvent);
+        pending.addCallResult(new CallResult("durable-call", new byte[] {1, 2, 
3}));
+        store.put(TEST_KEY, 1L, testAction, testEvent, pending);
         Object marker = store.getRecoveryMarker();
 
         // Write more data after marker
@@ -111,8 +113,19 @@ public class FlussActionStateStoreSaslIntegrationTest {
 
             // Data written after marker should be recovered
             assertThat(recoveredStore.get(TEST_KEY, 2L, testAction, 
testEvent)).isNotNull();
-            // Data written before marker should NOT be in the rebuilt cache
-            assertThat(recoveredStore.get(TEST_KEY, 1L, testAction, 
testEvent)).isNull();
+            // SASL must also preserve the pending result refreshed into the 
recovery window.
+            ActionState restored = recoveredStore.get(TEST_KEY, 1L, 
testAction, testEvent);
+            assertThat(restored).isNotNull();
+            assertThat(restored.isCompleted()).isFalse();
+            assertThat(restored.getCallResults())
+                    .singleElement()
+                    .satisfies(
+                            result -> {
+                                
assertThat(result.matches("durable-call")).isTrue();
+                                assertThat(result.isSuccess()).isTrue();
+                                assertThat(result.getResultPayload())
+                                        .containsExactly((byte) 1, (byte) 2, 
(byte) 3);
+                            });
         } finally {
             recoveredStore.close();
             store = null;
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreTest.java
index 5165cf0e..3c3d6b83 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/actionstate/FlussActionStateStoreTest.java
@@ -21,9 +21,14 @@ import org.apache.flink.agents.api.Event;
 import org.apache.flink.agents.api.InputEvent;
 import org.apache.flink.agents.plan.actions.Action;
 import org.apache.fluss.client.Connection;
+import org.apache.fluss.client.admin.Admin;
+import org.apache.fluss.client.admin.ListOffsetsResult;
+import org.apache.fluss.client.admin.OffsetSpec;
 import org.apache.fluss.client.table.Table;
 import org.apache.fluss.client.table.writer.AppendWriter;
+import org.apache.fluss.metadata.TableInfo;
 import org.apache.fluss.row.InternalRow;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
@@ -42,8 +47,11 @@ import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.assertj.core.api.Assertions.catchThrowable;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -60,6 +68,7 @@ public class FlussActionStateStoreTest {
     private Event testEvent;
     private ActionState testActionState;
     private Map<String, ActionState> actionStates;
+    private Admin mockAdmin;
 
     @BeforeEach
     void setUp() throws Exception {
@@ -76,12 +85,175 @@ public class FlussActionStateStoreTest {
     }
 
     private FlussActionStateStore createStore(int maxParallelism) {
+        Connection connection = mock(Connection.class);
+        Table table = mock(Table.class);
+        TableInfo tableInfo = mock(TableInfo.class);
+        mockAdmin = mock(Admin.class);
+        when(connection.getAdmin()).thenReturn(mockAdmin);
+        when(table.getTableInfo()).thenReturn(tableInfo);
+        when(tableInfo.getNumBuckets()).thenReturn(1);
+        when(mockAdmin.listOffsets(any(), any(), any(OffsetSpec.class)))
+                .thenReturn(
+                        new ListOffsetsResult(Map.of(0, 
CompletableFuture.completedFuture(10L))));
         return new FlussActionStateStore(
-                actionStates,
-                mock(Connection.class),
-                mock(Table.class),
-                mockWriter,
-                createKeyEncoder(maxParallelism));
+                actionStates, connection, table, mockWriter, 
createKeyEncoder(maxParallelism));
+    }
+
+    @AfterEach
+    void clearInterruptStatus() {
+        Thread.interrupted();
+    }
+
+    @Test
+    void testCheckpointRefreshesLatestStatesAfterCapturingOffsets() throws 
Exception {
+        store.put(TEST_KEY, 1L, testAction, testEvent, testActionState);
+        testActionState.addCallResult(new CallResult("call", new byte[] {1, 
2}));
+        store.put(TEST_KEY, 1L, testAction, testEvent, testActionState);
+        // A completed action may belong to an input whose other actions are 
still pending.
+        ActionState completed = new ActionState(testEvent);
+        completed.markCompleted();
+        store.put("other", 1L, testAction, testEvent, completed);
+        clearInvocations(mockAdmin, mockWriter);
+
+        assertThat(store.getRecoveryMarker()).isEqualTo(Map.of(0, 10L));
+
+        org.mockito.InOrder order = inOrder(mockAdmin, mockWriter);
+        order.verify(mockAdmin).listOffsets(any(), any(), 
any(OffsetSpec.LatestSpec.class));
+        ArgumentCaptor<InternalRow> rows = 
ArgumentCaptor.forClass(InternalRow.class);
+        order.verify(mockWriter, times(2)).append(rows.capture());
+        assertThat(rows.getAllValues())
+                .anySatisfy(
+                        row -> {
+                            ActionState saved = 
ActionStateSerde.deserialize(row.getBytes(1));
+                            assertThat(saved.isCompleted()).isFalse();
+                            assertThat(saved.getCallResults()).hasSize(1);
+                            assertThat(row.getString(2).toString())
+                                    .isEqualTo(
+                                            
ActionStateUtil.generateBusinessKeyIdentity(
+                                                    TEST_KEY, KEY_SERIALIZER));
+                        });
+        assertThat(rows.getAllValues())
+                .anySatisfy(
+                        row ->
+                                assertThat(
+                                                
ActionStateSerde.deserialize(row.getBytes(1))
+                                                        .isCompleted())
+                                        .isTrue());
+    }
+
+    @Test
+    void testCheckpointRefreshesOnlySequencesNeedingReplay() throws Exception {
+        ActionState completed = new ActionState(testEvent);
+        completed.markCompleted();
+        store.put(TEST_KEY, 1L, testAction, testEvent, completed);
+        store.put(TEST_KEY, 2L, testAction, testEvent, completed);
+        // Completed actions in an unfinished input still need replay 
protection.
+        store.put(TEST_KEY, 3L, testAction, testEvent, completed);
+        Action pendingAction = new NoOpAction("pending");
+        store.put(TEST_KEY, 3L, pendingAction, testEvent, testActionState);
+        store.put("no-boundary", 1L, testAction, testEvent, completed);
+        Map<String, ActionState> cachedBeforeCheckpoint = new 
HashMap<>(actionStates);
+        clearInvocations(mockWriter);
+
+        assertThat(store.getRecoveryMarker(Map.of(TEST_KEY, 
2L))).isEqualTo(Map.of(0, 10L));
+
+        ArgumentCaptor<InternalRow> rows = 
ArgumentCaptor.forClass(InternalRow.class);
+        verify(mockWriter, times(3)).append(rows.capture());
+        assertThat(rows.getAllValues())
+                .extracting(row -> row.getString(0).toString())
+                .containsExactlyInAnyOrder(
+                        generateKey(TEST_KEY, 3L, testAction, testEvent, 
MAX_PARALLELISM),
+                        generateKey(TEST_KEY, 3L, pendingAction, testEvent, 
MAX_PARALLELISM),
+                        generateKey("no-boundary", 1L, testAction, testEvent, 
MAX_PARALLELISM));
+        
assertThat(actionStates).containsExactlyInAnyOrderEntriesOf(cachedBeforeCheckpoint);
+
+        // Boundaries belong to one snapshot; a later unbounded marker must 
refresh every state.
+        clearInvocations(mockWriter);
+        store.getRecoveryMarker();
+        verify(mockWriter, times(5)).append(any());
+    }
+
+    @Test
+    void testCheckpointBoundariesSeparateTypedKeysWithSameStringForm() throws 
Exception {
+        store = createStore(1);
+        store.put(1L, 1L, testAction, testEvent, testActionState);
+        store.put("1", 1L, testAction, testEvent, testActionState);
+        clearInvocations(mockWriter);
+
+        store.getRecoveryMarker(Map.of(1L, 1L));
+
+        ArgumentCaptor<InternalRow> rows = 
ArgumentCaptor.forClass(InternalRow.class);
+        verify(mockWriter).append(rows.capture());
+        assertThat(rows.getValue().getString(0).toString())
+                .isEqualTo(generateKey("1", 1L, testAction, testEvent, 1));
+        assertThat(actionStates).hasSize(2);
+    }
+
+    @Test
+    void testCheckpointDoesNotRefreshPrunedOrDivergedStates() throws Exception 
{
+        store.put(TEST_KEY, 1L, testAction, testEvent, testActionState);
+        store.put(TEST_KEY, 2L, testAction, testEvent, testActionState);
+        store.put(TEST_KEY, 3L, testAction, testEvent, testActionState);
+        store.pruneState(TEST_KEY, 1L);
+        store.get(TEST_KEY, 2L, new NoOpAction("different"), testEvent);
+        clearInvocations(mockWriter);
+
+        store.getRecoveryMarker();
+        ArgumentCaptor<InternalRow> rows = 
ArgumentCaptor.forClass(InternalRow.class);
+        verify(mockWriter).append(rows.capture());
+        assertThat(rows.getValue().getString(0).toString())
+                .isEqualTo(generateKey(TEST_KEY, 2L, testAction, testEvent, 
MAX_PARALLELISM));
+        store.pruneState(TEST_KEY, 2L);
+        clearInvocations(mockWriter);
+        store.getRecoveryMarker();
+        verify(mockWriter, never()).append(any());
+    }
+
+    @Test
+    void testFailedCheckpointRefreshRetainsCacheForRetry() throws Exception {
+        store.put(TEST_KEY, 1L, testAction, testEvent, testActionState);
+        store.put("other", 1L, testAction, testEvent, testActionState);
+        when(mockWriter.append(any(InternalRow.class)))
+                .thenReturn(CompletableFuture.completedFuture(null))
+                .thenReturn(CompletableFuture.failedFuture(new 
IOException("append failed")));
+        assertThatThrownBy(store::getRecoveryMarker)
+                .hasMessageContaining("Failed to refresh")
+                .hasRootCauseMessage("append failed");
+        assertThat(actionStates).hasSize(2);
+
+        when(mockWriter.append(any(InternalRow.class)))
+                .thenReturn(CompletableFuture.completedFuture(null));
+        clearInvocations(mockWriter);
+        assertThat(store.getRecoveryMarker()).isEqualTo(Map.of(0, 10L));
+        verify(mockWriter, times(2)).append(any());
+    }
+
+    @Test
+    void testInterruptedRefreshRestoresInterruptFlag() throws Exception {
+        store.put(TEST_KEY, 1L, testAction, testEvent, testActionState);
+        CompletableFuture<org.apache.fluss.client.table.writer.AppendResult> 
write =
+                mock(CompletableFuture.class);
+        when(write.get()).thenThrow(new InterruptedException("cancelled"));
+        when(mockWriter.append(any(InternalRow.class))).thenReturn(write);
+
+        
assertThatThrownBy(store::getRecoveryMarker).hasCauseInstanceOf(InterruptedException.class);
+        assertThat(Thread.currentThread().isInterrupted()).isTrue();
+        assertThat(actionStates).hasSize(1);
+    }
+
+    @Test
+    void testInterruptedOffsetLookupDoesNotRefresh() throws Exception {
+        ListOffsetsResult offsets = mock(ListOffsetsResult.class);
+        CompletableFuture<Map<Integer, Long>> future = 
mock(CompletableFuture.class);
+        when(future.get()).thenThrow(new InterruptedException("cancelled"));
+        when(offsets.all()).thenReturn(future);
+        when(mockAdmin.listOffsets(any(), any(), 
any(OffsetSpec.class))).thenReturn(offsets);
+        store.put(TEST_KEY, 1L, testAction, testEvent, testActionState);
+        clearInvocations(mockWriter);
+
+        
assertThatThrownBy(store::getRecoveryMarker).hasCauseInstanceOf(InterruptedException.class);
+        assertThat(Thread.currentThread().isInterrupted()).isTrue();
+        verify(mockWriter, never()).append(any());
     }
 
     @Test
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/DurableExecutionManagerTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/DurableExecutionManagerTest.java
index c2bc5644..c949eaa6 100644
--- 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/DurableExecutionManagerTest.java
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/DurableExecutionManagerTest.java
@@ -39,8 +39,11 @@ import org.apache.flink.runtime.state.KeyedStateBackend;
 import org.apache.flink.runtime.state.KeyedStateFunction;
 import org.apache.flink.runtime.state.OperatorStateBackend;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.MockedConstruction;
 
+import java.io.IOException;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -48,10 +51,14 @@ import java.util.concurrent.atomic.AtomicReference;
 
 import static 
org.apache.flink.agents.api.configuration.AgentConfigOptions.ACTION_STATE_STORE_BACKEND;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.spy;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
@@ -107,19 +114,7 @@ class DurableExecutionManagerTest {
         assertThat(dem.maybeGetActionState("k", 0L, action, event)).isNull();
         dem.maybeInitActionState("k", 0L, action, event);
         dem.notifyCheckpointComplete(1L);
-        dem.snapshotRecoveryMarker(Map.of());
-        dem.close();
-    }
-
-    @Test
-    void snapshotRecoveryMarkerPassesCompletedSequencesToStore() throws 
Exception {
-        ActionStateStore store = mock(ActionStateStore.class);
-        DurableExecutionManager dem = new DurableExecutionManager(store);
-        Map<Object, Long> lastCompletedSequenceNumbers = Map.of("key", 3L);
-
-        dem.snapshotRecoveryMarker(lastCompletedSequenceNumbers);
-
-        verify(store).getRecoveryMarker(lastCompletedSequenceNumbers);
+        dem.snapshotState(null, 1L);
         dem.close();
     }
 
@@ -153,14 +148,14 @@ class DurableExecutionManagerTest {
 
         // Cycle 1: snapshot + notify with null store. The snapshot-side guard 
must short-circuit
         // before any backend access, and the cleanup-side guard must leave 
the map untouched.
-        dem.snapshotLastCompletedSequenceNumbers(backend, 1L);
+        dem.snapshotState(backend, 1L);
         assertThat(dem.getCheckpointIdToSeqNums()).isEmpty();
         verifyNoInteractions(backend);
         dem.notifyCheckpointComplete(1L);
         assertThat(dem.getCheckpointIdToSeqNums()).isEmpty();
 
         // Cycle 2: confirm the invariant holds across multiple checkpoints.
-        dem.snapshotLastCompletedSequenceNumbers(backend, 2L);
+        dem.snapshotState(backend, 2L);
         assertThat(dem.getCheckpointIdToSeqNums()).isEmpty();
         verifyNoInteractions(backend);
         dem.notifyCheckpointComplete(2L);
@@ -209,14 +204,24 @@ class DurableExecutionManagerTest {
     }
 
     /**
-     * Verifies {@code snapshotLastCompletedSequenceNumbers} captures the 
per-key sequence numbers
-     * returned by the keyed state backend and records them under the given 
checkpoint id.
+     * Verifies {@code snapshotState} captures the per-key sequence numbers 
returned by the keyed
+     * state backend and records them under the given checkpoint id.
      */
     @Test
     @SuppressWarnings("unchecked")
     void snapshotCapturesKeySequenceNumbers() throws Exception {
-        InMemoryActionStateStore store = new InMemoryActionStateStore(false);
+        ActionStateStore store = mock(ActionStateStore.class);
         DurableExecutionManager dem = new DurableExecutionManager(store);
+        OperatorStateBackend opBackend = mock(OperatorStateBackend.class);
+        ListState<Object> markerState = mock(ListState.class);
+        
when(opBackend.getUnionListState(any(ListStateDescriptor.class))).thenReturn(markerState);
+        dem.initRecoveryMarkerState(opBackend);
+        when(store.getRecoveryMarker(Map.of("k1", 10L, "k2", 20L)))
+                .thenAnswer(
+                        invocation -> {
+                            
assertThat(dem.getCheckpointIdToSeqNums()).isEmpty();
+                            return "test-marker";
+                        });
 
         KeyedStateBackend<Object> backend = mock(KeyedStateBackend.class);
         doAnswer(
@@ -229,24 +234,69 @@ class DurableExecutionManagerTest {
                             when(state2.value()).thenReturn(20L);
                             function.process("k1", state1);
                             function.process("k2", state2);
+                            function.process("no-boundary", 
mock(ValueState.class));
                             return null;
                         })
                 .when(backend)
                 .applyToAllKeys(any(), any(), any(), any());
 
-        Map<Object, Long> lastCompletedSequenceNumbers =
-                dem.snapshotLastCompletedSequenceNumbers(backend, 1000L);
+        dem.snapshotState(backend, 1000L);
 
-        assertThat(lastCompletedSequenceNumbers)
+        assertThat(dem.getCheckpointIdToSeqNums())
+                .containsOnlyKeys(1000L)
+                .extractingByKey(1000L)
+                .asInstanceOf(
+                        org.assertj.core.api.InstanceOfAssertFactories.map(
+                                Object.class, Long.class))
                 .containsEntry("k1", 10L)
                 .containsEntry("k2", 20L)
                 .hasSize(2);
-        assertThat(dem.getCheckpointIdToSeqNums())
-                .containsEntry(1000L, lastCompletedSequenceNumbers);
+        verify(store).getRecoveryMarker(Map.of("k1", 10L, "k2", 20L));
+        verify(markerState).update(List.of("test-marker"));
 
         dem.close();
     }
 
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    @SuppressWarnings("unchecked")
+    void failedSnapshotDoesNotRegisterPruningBoundaries(boolean 
failMarkerUpdate) throws Exception {
+        ActionStateStore store = mock(ActionStateStore.class);
+        DurableExecutionManager dem = new DurableExecutionManager(store);
+        KeyedStateBackend<Object> backend = mock(KeyedStateBackend.class);
+        stubApplyToAllKeysSingle(backend, "key", 1L);
+        OperatorStateBackend opBackend = mock(OperatorStateBackend.class);
+        ListState<Object> markerState = mock(ListState.class);
+        
when(opBackend.getUnionListState(any(ListStateDescriptor.class))).thenReturn(markerState);
+        dem.initRecoveryMarkerState(opBackend);
+        when(store.getRecoveryMarker(Map.of("key", 
1L))).thenReturn("marker-1");
+        dem.snapshotState(backend, 1L);
+
+        stubApplyToAllKeysSingle(backend, "key", 2L);
+        if (failMarkerUpdate) {
+            when(store.getRecoveryMarker(Map.of("key", 
2L))).thenReturn("marker-2");
+            doThrow(new IOException("marker update failed"))
+                    .when(markerState)
+                    .update(List.of("marker-2"));
+        } else {
+            when(store.getRecoveryMarker(Map.of("key", 2L)))
+                    .thenThrow(new RuntimeException("refresh failed"));
+        }
+
+        assertThatThrownBy(() -> dem.snapshotState(backend, 
2L)).hasMessageContaining("failed");
+        assertThat(dem.getCheckpointIdToSeqNums()).containsOnlyKeys(1L);
+        assertThat(dem.getCheckpointIdToSeqNums().get(1L))
+                .containsExactlyEntriesOf(Map.of("key", 1L));
+        if (!failMarkerUpdate) {
+            verify(markerState, never()).update(List.of("marker-2"));
+        }
+        dem.notifyCheckpointAborted(2L);
+        verify(store, never()).pruneState(any(), anyLong());
+        dem.notifyCheckpointComplete(1L);
+        verify(store).pruneState("key", 1L);
+        dem.close();
+    }
+
     /**
      * Verifies {@code notifyCheckpointComplete} prunes every captured key for 
the notified
      * checkpoint, leaves entries captured for other checkpoints intact, and 
removes the notified
@@ -338,7 +388,7 @@ class DurableExecutionManagerTest {
         @SuppressWarnings("unchecked")
         KeyedStateBackend<Object> backend = mock(KeyedStateBackend.class);
         stubApplyToAllKeysSingle(backend, key, seq);
-        dem.snapshotLastCompletedSequenceNumbers(backend, checkpointId);
+        dem.snapshotState(backend, checkpointId);
         assertThat(dem.getCheckpointIdToSeqNums()).containsKey(checkpointId);
 
         // Abort the checkpoint.
@@ -378,17 +428,17 @@ class DurableExecutionManagerTest {
         @SuppressWarnings("unchecked")
         KeyedStateBackend<Object> backendA = mock(KeyedStateBackend.class);
         stubApplyToAllKeysSingle(backendA, keyA, 10L);
-        dem.snapshotLastCompletedSequenceNumbers(backendA, 1L);
+        dem.snapshotState(backendA, 1L);
 
         @SuppressWarnings("unchecked")
         KeyedStateBackend<Object> backendB = mock(KeyedStateBackend.class);
         stubApplyToAllKeysSingle(backendB, keyB, 20L);
-        dem.snapshotLastCompletedSequenceNumbers(backendB, 2L);
+        dem.snapshotState(backendB, 2L);
 
         @SuppressWarnings("unchecked")
         KeyedStateBackend<Object> backendC = mock(KeyedStateBackend.class);
         stubApplyToAllKeysSingle(backendC, keyC, 30L);
-        dem.snapshotLastCompletedSequenceNumbers(backendC, 3L);
+        dem.snapshotState(backendC, 3L);
 
         assertThat(dem.getCheckpointIdToSeqNums()).containsKeys(1L, 2L, 3L);
 
@@ -424,7 +474,7 @@ class DurableExecutionManagerTest {
      * Stubs {@code backend.applyToAllKeys(...)} to invoke the supplied {@link 
KeyedStateFunction}
      * exactly once with the given key and a {@link ValueState} mock that 
returns the given sequence
      * number. This mirrors the per-key iteration shape used by {@link
-     * DurableExecutionManager#snapshotLastCompletedSequenceNumbers}.
+     * DurableExecutionManager#snapshotState}.
      */
     @SuppressWarnings("unchecked")
     private static void stubApplyToAllKeysSingle(
diff --git 
a/runtime/src/test/java/org/apache/flink/agents/runtime/operator/FlussActionStateRecoveryIntegrationTest.java
 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/FlussActionStateRecoveryIntegrationTest.java
new file mode 100644
index 00000000..a33d2c25
--- /dev/null
+++ 
b/runtime/src/test/java/org/apache/flink/agents/runtime/operator/FlussActionStateRecoveryIntegrationTest.java
@@ -0,0 +1,354 @@
+/*
+ * 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.flink.agents.runtime.operator;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.InputEvent;
+import org.apache.flink.agents.api.OutputEvent;
+import org.apache.flink.agents.api.agents.AgentExecutionOptions;
+import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.plan.AgentConfiguration;
+import org.apache.flink.agents.plan.AgentPlan;
+import org.apache.flink.agents.plan.JavaFunction;
+import org.apache.flink.agents.plan.actions.Action;
+import org.apache.flink.agents.runtime.actionstate.ActionState;
+import org.apache.flink.agents.runtime.actionstate.ActionStateKeyEncoder;
+import org.apache.flink.agents.runtime.actionstate.FlussActionStateStore;
+import org.apache.flink.agents.runtime.python.utils.PythonActionExecutor;
+import org.apache.flink.api.common.state.ListState;
+import org.apache.flink.api.common.state.ListStateDescriptor;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import org.apache.flink.api.common.typeutils.base.LongSerializer;
+import org.apache.flink.api.java.functions.KeySelector;
+import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
+import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.streaming.runtime.tasks.mailbox.TaskMailbox;
+import org.apache.flink.streaming.util.KeyedOneInputStreamOperatorTestHarness;
+import org.apache.fluss.client.Connection;
+import org.apache.fluss.client.ConnectionFactory;
+import org.apache.fluss.client.admin.Admin;
+import org.apache.fluss.client.table.writer.AppendWriter;
+import org.apache.fluss.metadata.TablePath;
+import org.apache.fluss.row.InternalRow;
+import org.apache.fluss.server.testutils.FlussClusterExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.apache.flink.agents.api.configuration.AgentConfigOptions.*;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** Real Fluss writes and operator checkpoints must preserve pending durable 
results. */
+public class FlussActionStateRecoveryIntegrationTest {
+    private static final int MAX_PARALLELISM = 128;
+    private static final AtomicInteger CALLS = new AtomicInteger();
+
+    @RegisterExtension
+    static final FlussClusterExtension FLUSS_CLUSTER =
+            FlussClusterExtension.builder().setNumOfTabletServers(1).build();
+
+    @Test
+    void pendingCallSurvivesRepeatedCheckpointRestoreAndCompletes() throws 
Exception {
+        CALLS.set(0);
+        Action action = countingAction();
+        AgentPlan plan = new AgentPlan(Map.of(action.getName(), action));
+        AgentConfiguration config = configuration();
+        config.set(FLUSS_ACTION_STATE_TABLE, "pending_results");
+        config.set(FLUSS_ACTION_STATE_TABLE_BUCKETS, 2);
+        OperatorSubtaskState snapshot;
+        Event pendingEvent;
+        try (FlussActionStateStore store = store(config);
+                KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness =
+                        harness(plan, store)) {
+            waitForTableReady(config);
+            harness.open();
+            ActionExecutionOperator<Long, Object> operator = operator(harness);
+            // Leave two completed inputs cached without notifying checkpoint 
completion.
+            // Their sequences are below and at this checkpoint's completed 
boundary.
+            for (int i = 0; i < 2; i++) {
+                harness.processElement(new StreamRecord<>(7L));
+                operator.waitInFlightEventsFinished();
+            }
+            assertThat(CALLS.get()).isEqualTo(2);
+            assertThat(harness.getRecordOutput()).hasSize(2);
+            harness.getOutput().clear();
+            harness.processElement(new StreamRecord<>(7L));
+            operator.setCurrentKey(7L);
+            ListState<ActionTask> tasks =
+                    operator.getRuntimeContext()
+                            .getListState(
+                                    new ListStateDescriptor<>(
+                                            "actionTasks", 
TypeInformation.of(ActionTask.class)));
+            ActionTask queued = tasks.get().iterator().next();
+            pendingEvent = queued.getEvent();
+            // Deterministically yield after the real durable call on every 
supported JDK.
+            // The operator handles the unfinished result and checkpoints a 
normal Java task.
+            tasks.update(List.of(new CallThenYieldTask(queued)));
+            harness.getTaskMailbox().take(TaskMailbox.MIN_PRIORITY).run();
+            assertThat(CALLS.get()).isEqualTo(3);
+            assertThat(harness.getRecordOutput()).isEmpty();
+            assertThat(tasks.get()).hasSize(1);
+            ActionState pending = store.get(7L, 2L, action, pendingEvent);
+            assertThat(pending.isCompleted()).isFalse();
+            assertThat(pending.getCallResults()).hasSize(1);
+            Field writerField = 
FlussActionStateStore.class.getDeclaredField("writer");
+            writerField.setAccessible(true);
+            AppendWriter writer = spy((AppendWriter) writerField.get(store));
+            writerField.set(store, writer);
+            clearInvocations(writer);
+            snapshot = harness.snapshot(1L, 1L);
+            // Only the unfinished input is refreshed, even before completed 
inputs are pruned.
+            verify(writer).append(any(InternalRow.class));
+            harness.notifyOfCompletedCheckpoint(1L);
+            assertThat(store.get(7L, 2L, action, pendingEvent)).isNotNull();
+        }
+
+        try (FlussActionStateStore store = store(config);
+                KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness =
+                        harness(plan, store)) {
+            harness.initializeState(snapshot);
+            harness.open();
+            assertThat(store.get(7L, 2L, action, pendingEvent)).isNotNull();
+            // Checkpoint again before replay runs: recovered cache entries 
need protection too.
+            snapshot = harness.snapshot(2L, 2L);
+            harness.notifyOfCompletedCheckpoint(2L);
+        }
+
+        try (FlussActionStateStore store = store(config);
+                KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness =
+                        harness(plan, store)) {
+            harness.initializeState(snapshot);
+            harness.open();
+            operator(harness).waitInFlightEventsFinished();
+            assertThat(harness.getRecordOutput()).hasSize(1);
+            
assertThat(harness.getRecordOutput().iterator().next().getValue()).isEqualTo(21L);
+            assertThat(CALLS.get()).as("persisted call must not execute 
again").isEqualTo(3);
+            snapshot = harness.snapshot(3L, 3L);
+            harness.notifyOfCompletedCheckpoint(3L);
+            assertThat(store.get(7L, 2L, action, pendingEvent)).isNull();
+            // Pruning must stop refreshing the completed input's records.
+            
assertThat(store.getRecoveryMarker()).isEqualTo(store.getRecoveryMarker());
+        }
+
+        // Completing and pruning after checkpoint 3 must not invalidate that 
checkpoint.
+        try (FlussActionStateStore store = store(config);
+                KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness =
+                        harness(plan, store)) {
+            harness.initializeState(snapshot);
+            harness.open();
+            operator(harness).waitInFlightEventsFinished();
+            assertThat(harness.getRecordOutput()).isEmpty();
+            assertThat(CALLS.get()).isEqualTo(3);
+        }
+    }
+
+    @Test
+    void partialRefreshFailurePreservesPreviousCheckpoint() throws Exception {
+        CALLS.set(0);
+        Action action = countingAction();
+        AgentPlan plan = new AgentPlan(Map.of(action.getName(), action));
+        AgentConfiguration config = configuration();
+        config.set(FLUSS_ACTION_STATE_TABLE, "partial_refresh_failure");
+        config.set(FLUSS_ACTION_STATE_TABLE_BUCKETS, 2);
+        OperatorSubtaskState successfulCheckpoint;
+        try (FlussActionStateStore store = store(config);
+                KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness =
+                        harness(plan, store)) {
+            waitForTableReady(config);
+            harness.open();
+            ActionExecutionOperator<Long, Object> operator = operator(harness);
+            // Queue both keys before yielding either task so neither 
continuation is resumed.
+            for (long key : List.of(7L, 8L)) {
+                harness.processElement(new StreamRecord<>(key));
+                operator.setCurrentKey(key);
+                ListState<ActionTask> tasks =
+                        operator.getRuntimeContext()
+                                .getListState(
+                                        new ListStateDescriptor<>(
+                                                "actionTasks",
+                                                
TypeInformation.of(ActionTask.class)));
+                tasks.update(List.of(new 
CallThenYieldTask(tasks.get().iterator().next())));
+            }
+            harness.getTaskMailbox().take(TaskMailbox.MIN_PRIORITY).run();
+            harness.getTaskMailbox().take(TaskMailbox.MIN_PRIORITY).run();
+            assertThat(CALLS.get()).isEqualTo(2);
+            assertThat(harness.getRecordOutput()).isEmpty();
+            successfulCheckpoint = harness.snapshot(1L, 1L);
+            harness.notifyOfCompletedCheckpoint(1L);
+
+            // Inject a writer failure only for C2. The first refresh append 
reaches the real
+            // cluster and is acknowledged before the second append fails.
+            Field writerField = 
FlussActionStateStore.class.getDeclaredField("writer");
+            writerField.setAccessible(true);
+            AppendWriter realWriter = (AppendWriter) writerField.get(store);
+            AppendWriter failingWriter = mock(AppendWriter.class);
+            AtomicInteger refreshAttempts = new AtomicInteger();
+            AtomicInteger acknowledgedRefreshes = new AtomicInteger();
+            when(failingWriter.append(any(InternalRow.class)))
+                    .thenAnswer(
+                            invocation -> {
+                                if (refreshAttempts.incrementAndGet() == 1) {
+                                    Object result =
+                                            
realWriter.append(invocation.getArgument(0)).get();
+                                    acknowledgedRefreshes.incrementAndGet();
+                                    return 
CompletableFuture.completedFuture(result);
+                                }
+                                return CompletableFuture.failedFuture(
+                                        new IOException("injected C2 refresh 
failure"));
+                            });
+            writerField.set(store, failingWriter);
+            try {
+                assertThatThrownBy(() -> harness.snapshot(2L, 2L))
+                        .hasRootCauseMessage("injected C2 refresh failure");
+                harness.getOperator().notifyCheckpointAborted(2L);
+                assertThat(refreshAttempts.get()).isEqualTo(2);
+                assertThat(acknowledgedRefreshes.get()).isEqualTo(1);
+            } finally {
+                writerField.set(store, realWriter);
+            }
+        }
+
+        try (FlussActionStateStore store = store(config);
+                KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness =
+                        harness(plan, store)) {
+            harness.initializeState(successfulCheckpoint);
+            harness.open();
+            operator(harness).waitInFlightEventsFinished();
+            assertThat(harness.getRecordOutput()).hasSize(2);
+            assertThat(harness.getRecordOutput())
+                    .allSatisfy(record -> 
assertThat(record.getValue()).isEqualTo(21L));
+            assertThat(CALLS.get())
+                    .as("neither durable call repeats after restoring C1")
+                    .isEqualTo(2);
+            harness.snapshot(3L, 3L);
+            harness.notifyOfCompletedCheckpoint(3L);
+            // Recovery must also leave checkpointing and normal 
completion/pruning usable.
+            
assertThat(store.getRecoveryMarker()).isEqualTo(store.getRecoveryMarker());
+        }
+    }
+
+    public static void runAction(Event event, RunnerContext context) throws 
Exception {
+        context.sendEvent(new OutputEvent(context.durableExecute(new 
CountingCall())));
+    }
+
+    private static class CountingCall implements DurableCallable<Long> {
+        public String getId() {
+            return "count";
+        }
+
+        public Class<Long> getResultClass() {
+            return Long.class;
+        }
+
+        public Long call() {
+            CALLS.incrementAndGet();
+            return 21L;
+        }
+    }
+
+    /**
+     * Only the suspension is simulated; persistence, task requeueing and 
replay use runtime code.
+     */
+    private static class CallThenYieldTask extends JavaActionTask {
+        CallThenYieldTask(ActionTask task) {
+            super(
+                    task.getKey(),
+                    task.getEvent(),
+                    task.getAction(),
+                    task.getSequenceNumber(),
+                    task.getTraceContext());
+        }
+
+        @Override
+        public ActionTaskResult invoke(ClassLoader loader, 
PythonActionExecutor executor)
+                throws Exception {
+            runnerContext.durableExecute(new CountingCall());
+            return new ActionTaskResult(
+                    false,
+                    List.of(),
+                    new JavaActionTask(key, event, action, sequenceNumber, 
traceContext));
+        }
+    }
+
+    private static Action countingAction() throws Exception {
+        return new Action(
+                "count",
+                new JavaFunction(
+                        FlussActionStateRecoveryIntegrationTest.class,
+                        "runAction",
+                        new Class<?>[] {Event.class, RunnerContext.class}),
+                List.of(InputEvent.EVENT_TYPE));
+    }
+
+    private static AgentConfiguration configuration() {
+        AgentConfiguration config = new AgentConfiguration();
+        config.set(FLUSS_BOOTSTRAP_SERVERS, 
FLUSS_CLUSTER.getBootstrapServers());
+        config.set(FLUSS_ACTION_STATE_DATABASE, "checkpoint_recovery");
+        return config;
+    }
+
+    private static void waitForTableReady(AgentConfiguration config) throws 
Exception {
+        try (Connection connection =
+                        
ConnectionFactory.createConnection(FLUSS_CLUSTER.getClientConfig());
+                Admin admin = connection.getAdmin()) {
+            TablePath path =
+                    TablePath.of(
+                            config.get(FLUSS_ACTION_STATE_DATABASE),
+                            config.get(FLUSS_ACTION_STATE_TABLE));
+            
FLUSS_CLUSTER.waitUntilTableReady(admin.getTableInfo(path).get().getTableId());
+        }
+    }
+
+    private static FlussActionStateStore store(AgentConfiguration config) {
+        return new FlussActionStateStore(
+                config, new ActionStateKeyEncoder(MAX_PARALLELISM, 
LongSerializer.INSTANCE));
+    }
+
+    private static KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness(
+            AgentPlan plan, FlussActionStateStore store) throws Exception {
+        // These tests replace queued tasks and drive the mailbox to simulate 
suspension.
+        plan.getConfig().set(AgentExecutionOptions.PARALLEL_EXECUTION_ENABLED, 
false);
+        return new KeyedOneInputStreamOperatorTestHarness<>(
+                new ActionExecutionOperatorFactory<>(plan, true, store),
+                (KeySelector<Long, Long>) value -> value,
+                TypeInformation.of(Long.class),
+                MAX_PARALLELISM,
+                1,
+                0);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static ActionExecutionOperator<Long, Object> operator(
+            KeyedOneInputStreamOperatorTestHarness<Long, Long, Object> 
harness) {
+        return (ActionExecutionOperator<Long, Object>) harness.getOperator();
+    }
+}

Reply via email to