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

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


The following commit(s) were added to refs/heads/main by this push:
     new 866bca5d62 Flink: ConvertEqualityDeletes: Fix index consistency after 
a failure or restore (#18674)
866bca5d62 is described below

commit 866bca5d6291d19b3480bb55529c9eebec82d0d2
Author: Maximilian Michels <[email protected]>
AuthorDate: Tue Nov 3 16:28:01 2026 +0100

    Flink: ConvertEqualityDeletes: Fix index consistency after a failure or 
restore (#18674)
---
 docs/docs/flink-maintenance.md                     |  12 +-
 .../operator/EqualityConvertCommitter.java         |  56 +++-
 .../maintenance/operator/EqualityConvertPlan.java  |  19 +-
 .../operator/EqualityConvertPlanner.java           | 132 +++++---
 .../api/TestConvertEqualityDeletes.java            | 108 +++++++
 .../api/TestConvertEqualityDeletesE2E.java         | 111 ++++++-
 .../operator/TestEqualityConvertCommitter.java     | 198 +++++++++++-
 .../operator/TestEqualityConvertDVWriter.java      |  16 +-
 .../operator/TestEqualityConvertPlanner.java       | 338 ++++++++++++++++++++-
 9 files changed, 907 insertions(+), 83 deletions(-)

diff --git a/docs/docs/flink-maintenance.md b/docs/docs/flink-maintenance.md
index 37be8445f7..eac8d84a08 100644
--- a/docs/docs/flink-maintenance.md
+++ b/docs/docs/flink-maintenance.md
@@ -113,10 +113,16 @@ Notes:
 
 **State and restart**
 
-- The PK-index worker keeps a keyed row-position index of the target branch in 
Flink state and **maintains it incrementally across checkpoints**: each trigger 
cycle only applies the commits added on the target branch since the last 
indexed snapshot, and the resulting index is persisted with the next Flink 
checkpoint. On failover, the worker resumes from the most recent checkpointed 
index rather than rebuilding from scratch. Its size scales with the number of 
live rows in the table for th [...]
-- Full (re)indexing from the target branch head is only triggered in a few 
cases: cold start with no checkpoint, external commits that advance the target 
past the currently-indexed snapshot (see below), when the planner detects that 
the target branch has diverged from the indexed snapshot (rollback / 
replace-main / expired marker), or when a staging snapshot is planned again 
because the cycle that planned it did not commit.
+- The PK-index worker keeps a keyed row-position index of the target branch in 
Flink state, persisted with each Flink checkpoint. Its size scales with the 
number of live rows in the table for the configured equality columns; size 
Flink state backend, checkpoint storage, and TaskManager memory accordingly. 
**RocksDB is the preferred state backend** because the PK index can grow beyond 
the available heap and RocksDB spills to local disk instead of failing with an 
`OutOfMemoryError`.
+- The index is **reused across triggers and restored on failover** while it 
matches the head of the target branch: the head is the snapshot the index was 
built from, or the converter's own commit for the last planned staging snapshot 
directly on top of it. Each cycle applies its staging snapshot's changes to the 
index, so consecutive conversions do not rebuild it.
+- Otherwise the next trigger **rebuilds the index** from the target branch 
head, reading the equality columns of every live data file. This happens:
+    - on cold start without a checkpoint, and on the first trigger after 
restoring state written by an older Iceberg version
+    - after any other commit on the target branch, e.g. compaction, external 
writes, rollback, or replace-main (see *Concurrent commits on the target 
branch* below)
+    - after restoring a checkpoint that predates the latest conversion commit
+    - when the last planned conversion did not commit
+    - when an earlier rebuild did not finish
 - The `equalityFieldColumns` set is persisted in operator state. Reconfiguring 
it across a restart from savepoint is **not supported** and the job will fail 
fast on restore. Restart from a clean state (no savepoint) if the equality 
columns change.
-- The committer is intentionally stateless. On restart, the planner 
rediscovers its position by walking the target branch for the marker property 
`equality-convert-staging-snapshot` written by the committer. If that marker is 
no longer reachable on the target branch (target was rolled back, 
replace-main'ed, or the marker snapshot was expired) the planner fails and 
requires manual intervention.
+- On restart, the planner rediscovers its position by walking the target 
branch for the marker property `equality-convert-staging-snapshot` written by 
the committer. If that marker is no longer reachable on the target branch 
(target was rolled back, replaced, or the marker snapshot was expired) the 
planner fails and requires manual intervention.
 
 **Concurrent commits on the target branch**
 
diff --git 
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/EqualityConvertCommitter.java
 
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/EqualityConvertCommitter.java
index 34d996c3d9..0e2fe43df4 100644
--- 
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/EqualityConvertCommitter.java
+++ 
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/EqualityConvertCommitter.java
@@ -22,8 +22,13 @@ import java.util.List;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.apache.flink.annotation.Internal;
+import org.apache.flink.api.common.state.ListState;
+import org.apache.flink.api.common.state.ListStateDescriptor;
+import org.apache.flink.api.common.typeinfo.Types;
 import org.apache.flink.metrics.Counter;
 import org.apache.flink.metrics.MetricGroup;
+import org.apache.flink.runtime.state.StateInitializationContext;
+import org.apache.flink.runtime.state.StateSnapshotContext;
 import org.apache.flink.streaming.api.operators.AbstractStreamOperator;
 import org.apache.flink.streaming.api.operators.TwoInputStreamOperator;
 import org.apache.flink.streaming.api.watermark.Watermark;
@@ -36,6 +41,7 @@ import 
org.apache.iceberg.exceptions.CommitStateUnknownException;
 import org.apache.iceberg.flink.TableLoader;
 import org.apache.iceberg.flink.maintenance.api.Trigger;
 import 
org.apache.iceberg.relocated.com.google.common.annotations.VisibleForTesting;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.collect.Lists;
 import org.apache.iceberg.relocated.com.google.common.collect.Sets;
 import org.apache.iceberg.util.ContentFileUtil;
@@ -66,7 +72,13 @@ import org.slf4j.LoggerFactory;
  * completes cleanly. Errors are reported via {@link 
TaskResultAggregator#ERROR_STREAM} side output;
  * the Aggregator collects them and surfaces failure on its own watermark.
  *
- * <p>The committer is intentionally stateless: {@code bufferedResults} and 
{@code planResult} are
+ * <p>The committer keeps the index generation of the last failed cycle as 
checkpointed state. A
+ * failure in the readers or workers while the index is updated in a cycle 
with nothing to convert
+ * may have left the index without some rows, but that cycle has no commit to 
withhold. The
+ * committer withholds the next conversion commit on that generation instead: 
the next trigger then
+ * plans that staging snapshot again, which makes the planner rebuild the 
index on a new generation.
+ * The failure was reported where it happened, so the withheld cycle completes 
without an error.
+ * Apart from that the committer is stateless: {@code bufferedResults} and 
{@code planResult} are
  * not checkpointed. The maintenance framework ensures mutual exclusive tasks, 
and on restart the
  * planner re-derives its position from {@link 
#COMMITTED_STAGING_SNAPSHOT_PROPERTY} on main, so the
  * committer never receives a plan for an already-committed staging snapshot.
@@ -93,6 +105,8 @@ public class EqualityConvertCommitter extends 
AbstractStreamOperator<Trigger>
   private transient Table table;
   private transient List<DVWriteResult> bufferedResults;
   private transient EqualityConvertPlan planResult;
+  private transient ListState<Long> failedIndexGenerationState;
+  private transient long failedIndexGeneration;
 
   private transient Counter errorCounter;
   private transient Counter addedDataFileNumCounter;
@@ -136,6 +150,32 @@ public class EqualityConvertCommitter extends 
AbstractStreamOperator<Trigger>
     this.commitDurationMsCounter = 
taskMetricGroup.counter(COMMIT_DURATION_MS_METRIC);
   }
 
+  @Override
+  public void initializeState(StateInitializationContext context) throws 
Exception {
+    super.initializeState(context);
+    failedIndexGenerationState =
+        context
+            .getOperatorStateStore()
+            .getListState(new ListStateDescriptor<>("failedIndexGeneration", 
Types.LONG));
+
+    failedIndexGeneration = EqualityConvertPlan.NO_INDEX_GENERATION;
+    for (Long stateValue : failedIndexGenerationState.get()) {
+      Preconditions.checkState(
+          failedIndexGeneration == EqualityConvertPlan.NO_INDEX_GENERATION,
+          "failedIndexGeneration state should hold at most one value");
+      failedIndexGeneration = stateValue;
+    }
+  }
+
+  @Override
+  public void snapshotState(StateSnapshotContext context) throws Exception {
+    super.snapshotState(context);
+    failedIndexGenerationState.clear();
+    if (failedIndexGeneration != EqualityConvertPlan.NO_INDEX_GENERATION) {
+      failedIndexGenerationState.add(failedIndexGeneration);
+    }
+  }
+
   @Override
   public void processElement1(StreamRecord<DVWriteResult> record) {
     bufferedResults.add(record.getValue());
@@ -196,6 +236,7 @@ public class EqualityConvertCommitter extends 
AbstractStreamOperator<Trigger>
             "Skipping commit for table {} task {}: a DV writer reported an 
error.",
             tableName,
             taskName);
+        failedIndexGeneration = planResult.indexGeneration();
         deleteUncommittedDVs();
         return;
       }
@@ -209,6 +250,19 @@ public class EqualityConvertCommitter extends 
AbstractStreamOperator<Trigger>
       return;
     }
 
+    if (planResult.indexGeneration() == failedIndexGeneration) {
+      LOG.warn(
+          "Not committing staging snapshot {} for table {} task {}: an index 
update failed on "
+              + "index generation {}, so the next trigger rebuilds the index 
and converts the "
+              + "snapshot again.",
+          planResult.stagingSnapshotId(),
+          tableName,
+          taskName,
+          failedIndexGeneration);
+      deleteUncommittedDVs();
+      return;
+    }
+
     table.refresh();
 
     List<DeleteFile> allDvFiles = Lists.newArrayList();
diff --git 
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/EqualityConvertPlan.java
 
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/EqualityConvertPlan.java
index 4aa326d19a..3226fbf4a2 100644
--- 
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/EqualityConvertPlan.java
+++ 
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/EqualityConvertPlan.java
@@ -31,8 +31,8 @@ import 
org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
  * EqualityConvertCommitter} (for data files and staging deletes to commit).
  *
  * <p>A no-op cycle is encoded by {@link #stagingSnapshotId()} == {@link
- * #NO_OP_STAGING_SNAPSHOT_ID}. Use {@link #noOp(Long, long, long)} to 
construct one and {@link
- * #noOp()} to check.
+ * #NO_OP_STAGING_SNAPSHOT_ID}. Use {@link #noOp(Long, long, long, long)} to 
construct one and
+ * {@link #noOp()} to check.
  *
  * <p>A cycle-start marker is a no-op plan whose done timestamp is never 
reached. Use {@link
  * #cycleStart(long)} to construct one.
@@ -50,6 +50,9 @@ import 
org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
  * @param triggerTimestamp original trigger timestamp, forwarded by the 
Committer to the Aggregator
  * @param doneTimestamp timestamp after which all phase watermarks have been 
emitted; the DVWriter
  *     should only process the result when the watermark reaches or exceeds 
this value
+ * @param indexGeneration generation of the worker index the cycle ran on. The 
committer does not
+ *     commit a cycle on a generation whose update failed. {@link 
#NO_INDEX_GENERATION} for the
+ *     cycle-start marker.
  */
 @Internal
 public record EqualityConvertPlan(
@@ -59,11 +62,14 @@ public record EqualityConvertPlan(
     long stagingSnapshotId,
     Long mainSnapshotId,
     long triggerTimestamp,
-    long doneTimestamp)
+    long doneTimestamp,
+    long indexGeneration)
     implements Serializable {
 
   public static final long NO_OP_STAGING_SNAPSHOT_ID = -1L;
 
+  public static final long NO_INDEX_GENERATION = -1L;
+
   /** Returns {@code true} if this cycle has nothing to commit. */
   public boolean noOp() {
     return stagingSnapshotId == NO_OP_STAGING_SNAPSHOT_ID;
@@ -71,7 +77,7 @@ public record EqualityConvertPlan(
 
   /** No-op cycle: empty file lists, sentinel staging snapshot id. */
   public static EqualityConvertPlan noOp(
-      Long mainSnapshotId, long triggerTimestamp, long doneTimestamp) {
+      Long mainSnapshotId, long triggerTimestamp, long doneTimestamp, long 
indexGeneration) {
     return new EqualityConvertPlan(
         ImmutableList.of(),
         ImmutableList.of(),
@@ -79,7 +85,8 @@ public record EqualityConvertPlan(
         NO_OP_STAGING_SNAPSHOT_ID,
         mainSnapshotId,
         triggerTimestamp,
-        doneTimestamp);
+        doneTimestamp,
+        indexGeneration);
   }
 
   /**
@@ -87,6 +94,6 @@ public record EqualityConvertPlan(
    * until the cycle's actual plan replaces it.
    */
   public static EqualityConvertPlan cycleStart(long triggerTimestamp) {
-    return noOp(null, triggerTimestamp, Long.MAX_VALUE);
+    return noOp(null, triggerTimestamp, Long.MAX_VALUE, NO_INDEX_GENERATION);
   }
 }
diff --git 
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/EqualityConvertPlanner.java
 
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/EqualityConvertPlanner.java
index 96e5861f1c..1bf08b0976 100644
--- 
a/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/EqualityConvertPlanner.java
+++ 
b/flink/v2.3/flink/src/main/java/org/apache/iceberg/flink/maintenance/operator/EqualityConvertPlanner.java
@@ -70,9 +70,8 @@ import org.slf4j.LoggerFactory;
  * <ol>
  *   <li>{@link #refreshStagingCursor}: updates {@link #lastStagingSnapshotId} 
from the most recent
  *       committer marker on the target branch.
- *   <li>{@link #ensureIndexCurrent}: rebuilds the worker index from main when 
there is no index
- *       yet, when external commits (e.g. compaction) have advanced main, or 
when the staging
- *       snapshot picked for this cycle is the one the previous cycle planned.
+ *   <li>{@link #ensureIndexCurrent}: rebuilds the worker index from main 
unless it reflects the
+ *       target branch head, see {@link #indexReflects}.
  *   <li>{@link #processStagingSnapshot}: resolve the chosen staging 
snapshot's eq deletes against
  *       the (now-current) index, pass through any DV files, and index the 
snapshot's new data files
  *       for the next cycle.
@@ -109,6 +108,8 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
   private static final String SKIPPED_NO_OP_CYCLES_METRIC = 
"skippedNoOpCycles";
   private static final String REINDEX_COUNT_METRIC = "reindexCount";
 
+  private static final int STATE_VERSION = 1;
+
   private final String tableName;
   private final String taskName;
   private final TableLoader tableLoader;
@@ -123,10 +124,12 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
   private transient ListState<Long> indexSnapshotState;
   // Generation of the worker's current index build.
   private transient ListState<Long> indexGenerationState;
-  // Staging snapshot the last emitted plan covered.
+  // Staging snapshot the index changed for until its conversion commit shows 
up on the target.
   private transient ListState<Long> pendingStagingSnapshotState;
   // Equality field IDs the index was built with, allows to detect 
reconfiguration.
   private transient ListState<Integer> eqFieldIdsState;
+  // Absent in state of a version that recorded the index as current before it 
was complete.
+  private transient ListState<Integer> stateVersionState;
 
   private transient Table table;
 
@@ -134,8 +137,8 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
   private transient Long lastStagingSnapshotId;
   private transient Long indexSnapshotId;
   private transient long indexGeneration;
-  // Staging snapshot the last emitted plan covered, checkpointed so it 
survives a restore taken
-  // mid-cycle. Selecting it again means that cycle never committed.
+  // Staging snapshot the index changed for until its conversion commit shows 
up on the target
+  // branch, checkpointed so it survives a restore taken mid-cycle.
   private transient Long pendingStagingSnapshotId;
 
   private transient long nextPhaseTs;
@@ -238,6 +241,20 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
           "pendingStagingSnapshotId state should hold at most one value");
       pendingStagingSnapshotId = stateValue;
     }
+
+    stateVersionState =
+        context
+            .getOperatorStateStore()
+            .getListState(new ListStateDescriptor<>("stateVersion", 
Types.INT));
+
+    // The index recorded by an older version can be incomplete while it looks 
current.
+    if (context.isRestored() && !stateVersionState.get().iterator().hasNext()) 
{
+      LOG.info(
+          "Restored state of an older version for table {} task {}, rebuilding 
the worker index.",
+          tableName,
+          taskName);
+      indexSnapshotId = null;
+    }
   }
 
   @Override
@@ -260,6 +277,9 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
     if (pendingStagingSnapshotId != null) {
       pendingStagingSnapshotState.add(pendingStagingSnapshotId);
     }
+
+    stateVersionState.clear();
+    stateVersionState.add(STATE_VERSION);
   }
 
   @Override
@@ -279,12 +299,12 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
       Snapshot mainSnapshot = table.snapshot(targetBranch);
       currentMainSnapshotId = mainSnapshot != null ? mainSnapshot.snapshotId() 
: null;
 
-      LastCommittedWork committedWork = refreshStagingCursor(mainSnapshot);
+      refreshStagingCursor(mainSnapshot);
 
       Snapshot nextToProcess =
           nextUnprocessedStagingSnapshot(table.snapshot(stagingBranch), 
mainSnapshot);
 
-      ensureIndexCurrent(mainSnapshot, committedWork, nextToProcess);
+      ensureIndexCurrent(mainSnapshot);
 
       if (nextToProcess == null) {
         LOG.info("Nothing new to convert on staging branch '{}'.", 
stagingBranch);
@@ -303,45 +323,35 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
 
   /**
    * Updates {@link #lastStagingSnapshotId} from the most recent committer 
marker on the target
-   * branch. Returns the discovered work, or null when the target has not 
moved since the last
-   * trigger and the cursor therefore cannot have changed.
+   * branch. Does nothing when the target has not moved since the last trigger 
and the cursor
+   * therefore cannot have changed.
    */
-  private LastCommittedWork refreshStagingCursor(Snapshot mainSnapshot) {
+  private void refreshStagingCursor(Snapshot mainSnapshot) {
     Long currentMainSnapshotId = mainSnapshot != null ? 
mainSnapshot.snapshotId() : null;
 
     if (Objects.equals(lastMainSnapshotId, currentMainSnapshotId)) {
-      return null;
+      return;
     }
 
-    LastCommittedWork info = discoverLastCommittedWork(mainSnapshot);
-    updateLastStagingSnapshotId(info);
-    return info;
+    updateLastStagingSnapshotId(discoverLastCommittedWork(mainSnapshot));
   }
 
   /**
-   * Rebuilds the worker index when it is missing, when external commits have 
advanced the target
-   * branch, or when {@code nextToProcess} is the staging snapshot the 
previous plan covered.
-   * Resolving an eq delete consumes the index entries it matches, so a cycle 
that failed after its
-   * delete phase left the index without them; the cursor only advances once 
the committer's marker
-   * is on the target branch, so planning the same staging snapshot again 
means that cycle did not
-   * commit and nothing else has rebuilt the index.
+   * Rebuilds the worker index unless it reflects {@code mainSnapshot}, see 
{@link #indexReflects}.
    */
-  private void ensureIndexCurrent(
-      Snapshot mainSnapshot, LastCommittedWork committedWork, Snapshot 
nextToProcess) {
+  private void ensureIndexCurrent(Snapshot mainSnapshot) {
     if (mainSnapshot == null) {
+      // The target branch has no rows, so the index must not have any either. 
A new generation
+      // makes the worker drop the rows indexed so far, e.g. by a cycle that 
did not commit.
+      indexGeneration++;
+      indexSnapshotId = null;
+      pendingStagingSnapshotId = null;
       lastMainSnapshotId = null;
       return;
     }
 
-    boolean bootstrap = indexSnapshotId == null;
-    boolean reindex =
-        !bootstrap && committedWork != null && 
committedWork.externalCommitCount() > 0;
-    boolean replan =
-        !bootstrap
-            && nextToProcess != null
-            && Objects.equals(pendingStagingSnapshotId, 
nextToProcess.snapshotId());
-
-    if (bootstrap || reindex || replan) {
+    if (!indexReflects(mainSnapshot)) {
+      boolean bootstrap = indexSnapshotId == null;
       LOG.info(
           "{} worker index from main snapshot {} for field IDs {}.",
           bootstrap ? "Bootstrapping" : "Reindexing",
@@ -350,20 +360,58 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
       rebuildIndex(mainSnapshot, !bootstrap);
     }
 
+    indexSnapshotId = mainSnapshot.snapshotId();
+    pendingStagingSnapshotId = null;
     lastMainSnapshotId = mainSnapshot.snapshotId();
   }
 
+  /**
+   * Returns whether the index reflects {@code mainSnapshot}.
+   *
+   * <p>Planning a staging snapshot changes the index before its conversion 
commit exists: its eq
+   * deletes consume the entries they match and its data files are added. So 
with a snapshot
+   * planned, the index reflects the head only if that snapshot's conversion 
commit sits directly on
+   * top of the indexed snapshot. Without one, the head must be the indexed 
snapshot. Any other head
+   * holds commits the index never saw (external commits, rollbacks, a restore 
from before a
+   * conversion commit) or follows a cycle that did not commit.
+   *
+   * <p>After a restore, the conversion commit can come from a retry of the 
planned cycle. A cycle
+   * that only failed to commit leaves the same index as its retry. A rebuild 
that fails part way
+   * clears {@link #indexSnapshotId}, and the committer does not commit a 
conversion on a generation
+   * whose update failed downstream.
+   */
+  private boolean indexReflects(Snapshot mainSnapshot) {
+    if (indexSnapshotId == null) {
+      return false;
+    }
+
+    if (pendingStagingSnapshotId == null) {
+      return mainSnapshot.snapshotId() == indexSnapshotId;
+    }
+
+    String committedStaging =
+        
mainSnapshot.summary().get(EqualityConvertCommitter.COMMITTED_STAGING_SNAPSHOT_PROPERTY);
+    return committedStaging != null
+        && Long.parseLong(committedStaging) == pendingStagingSnapshotId
+        && Objects.equals(mainSnapshot.parentId(), indexSnapshotId);
+  }
+
   /**
    * Re-emits every data row on {@code mainSnapshot} so the worker's index 
holds all their positions
    * again, optionally preceded by a CLEAR_INDEX broadcast that evicts keyed 
entries the re-emission
    * will not re-add (e.g. a PK whose data file was removed by a CoW commit). 
A bootstrap has no
-   * earlier index and so nothing to evict.
+   * complete earlier index. Entries of an incomplete one are ignored at the 
new generation.
    *
    * <p>The worker detects stale state by comparing the generation stamped on 
the commands it
    * receives with the one it stored, so every rebuild hands out a higher 
generation, including a
    * rebuild while the target branch stands still.
+   *
+   * <p>Clears {@link #indexSnapshotId} before emitting anything, and the 
caller records the head
+   * only after this returns. Neither the next trigger nor a restore from a 
checkpoint taken after a
+   * rebuild that failed part way trusts the index it left.
    */
   private void rebuildIndex(Snapshot mainSnapshot, boolean evictStaleKeys) {
+    indexSnapshotId = null;
     long generation = indexGeneration + 1;
 
     if (evictStaleKeys) {
@@ -374,7 +422,6 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
       reindexCounter.inc();
     }
 
-    indexSnapshotId = mainSnapshot.snapshotId();
     indexGeneration = generation;
     emitMainDataReadCommands(mainSnapshot);
   }
@@ -399,7 +446,7 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
   /**
    * Walks main back from head looking for the most recent snapshot tagged 
with {@link
    * EqualityConvertCommitter#COMMITTED_STAGING_SNAPSHOT_PROPERTY}. Returns 
the staging snapshot id
-   * recorded there (or {@code null} if not reached) and the count of 
intervening external commits.
+   * recorded there, or {@code null} if not reached.
    *
    * <p>The walk stops at whichever comes first:
    *
@@ -410,7 +457,6 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
    */
   private LastCommittedWork discoverLastCommittedWork(Snapshot mainSnapshot) {
     Long lastCommittedStaging = null;
-    int externalCount = 0;
     boolean reachedLastInspected = false;
     Snapshot current = mainSnapshot;
     while (current != null) {
@@ -426,15 +472,13 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
         break;
       }
 
-      externalCount++;
       current = parentOf(current);
     }
 
-    return new LastCommittedWork(lastCommittedStaging, externalCount, 
reachedLastInspected);
+    return new LastCommittedWork(lastCommittedStaging, reachedLastInspected);
   }
 
-  private record LastCommittedWork(
-      Long lastCommittedStaging, int externalCommitCount, boolean 
reachedLastInspected) {}
+  private record LastCommittedWork(Long lastCommittedStaging, boolean 
reachedLastInspected) {}
 
   /**
    * Walks staging history from head back to the stop point and returns the 
oldest unprocessed
@@ -533,7 +577,8 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
                 stagingSnapshot.snapshotId(),
                 currentMainSnapshotId,
                 triggerTs,
-                nextPhaseTs)));
+                nextPhaseTs,
+                indexGeneration)));
 
     advancePhase();
   }
@@ -680,7 +725,8 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
     output.collect(
         METADATA_STREAM,
         new StreamRecord<>(
-            EqualityConvertPlan.noOp(currentMainSnapshotId, triggerTimestamp, 
nextPhaseTs)));
+            EqualityConvertPlan.noOp(
+                currentMainSnapshotId, triggerTimestamp, nextPhaseTs, 
indexGeneration)));
     advancePhase();
   }
 
@@ -700,7 +746,7 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
         output.collect(
             new StreamRecord<>(
                 ReadCommand.dataFile(
-                    task, indexSnapshotId, indexGeneration, 
dataSequenceNumber(task.file())),
+                    task, commitSnapshotId, indexGeneration, 
dataSequenceNumber(task.file())),
                 nextPhaseTs));
       }
     } catch (IOException e) {
diff --git 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestConvertEqualityDeletes.java
 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestConvertEqualityDeletes.java
index 2e03a0655e..486ab0fe42 100644
--- 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestConvertEqualityDeletes.java
+++ 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestConvertEqualityDeletes.java
@@ -1473,6 +1473,114 @@ class TestConvertEqualityDeletes extends 
MaintenanceTaskTestBase {
     }
   }
 
+  @Test
+  void testIndexUpdateFailureOnIdleTriggerIsRepaired() throws Exception {
+    Table table = createTableWithDelete(3);
+    insert(table, 1, "a");
+
+    table.manageSnapshots().createBranch(STAGING_BRANCH).commit();
+    table.refresh();
+
+    DeleteFile firstDelete = writeEqualityDelete(table, 1, "a");
+    
table.newRowDelta().addDeletes(firstDelete).toBranch(STAGING_BRANCH).commit();
+
+    appendConvertTask(STAGING_BRANCH);
+
+    JobClient jobClient = null;
+    try {
+      jobClient = infra.env().executeAsync();
+
+      long time1 = System.currentTimeMillis();
+      infra.source().sendRecord(Trigger.create(time1, 0), time1);
+      assertThat(infra.sink().poll(Duration.ofSeconds(10)).success()).isTrue();
+
+      // An external commit lands while staging is idle, and its data file is 
unreadable while the
+      // next trigger updates the index.
+      DataFile externalData = writeDataFile(table, createRecord(2, "b"));
+      table.newAppend().appendFile(externalData).commit();
+      File externalFile = new File(externalData.location().replace("file:", 
""));
+      File hiddenFile = new File(externalFile.getPath() + ".hidden");
+      assertThat(externalFile.renameTo(hiddenFile)).isTrue();
+
+      long time2 = time1 + 1;
+      infra.source().sendRecord(Trigger.create(time2, 0), time2);
+      
assertThat(infra.sink().poll(Duration.ofSeconds(10)).success()).isFalse();
+
+      assertThat(hiddenFile.renameTo(externalFile)).isTrue();
+
+      DeleteFile secondDelete = writeEqualityDelete(table, 2, "b");
+      
table.newRowDelta().addDeletes(secondDelete).toBranch(STAGING_BRANCH).commit();
+
+      // The index may miss the external row, so the next conversion is not 
committed.
+      table.refresh();
+      long mainSnapshotId = table.currentSnapshot().snapshotId();
+      long time3 = time2 + 1;
+      infra.source().sendRecord(Trigger.create(time3, 0), time3);
+      assertThat(infra.sink().poll(Duration.ofSeconds(10)).success()).isTrue();
+      table.refresh();
+      
assertThat(table.currentSnapshot().snapshotId()).isEqualTo(mainSnapshotId);
+
+      // Planned again, the conversion rebuilds the index and deletes the 
external row.
+      long time4 = time3 + 1;
+      infra.source().sendRecord(Trigger.create(time4, 0), time4);
+      assertThat(infra.sink().poll(Duration.ofSeconds(10)).success()).isTrue();
+
+      assertRecords(table, ImmutableList.of());
+    } finally {
+      closeJobClient(jobClient);
+    }
+  }
+
+  @Test
+  void testFailedConversionOnEmptyTargetIsRetried() throws Exception {
+    Table table = createTableWithDelete(3);
+
+    // The target branch has no snapshot until the first conversion commit.
+    table.manageSnapshots().createBranch(STAGING_BRANCH).commit();
+    table.refresh();
+
+    // An upsert of (1, "a") and an insert whose data file is unreadable 
during the first cycle.
+    DataFile reinsert = writeDataFile(table, createRecord(1, "a"));
+    DataFile insert = writeDataFile(table, createRecord(2, "b"));
+    DeleteFile eqDelete = writeEqualityDelete(table, 1, "a");
+    table
+        .newRowDelta()
+        .addRows(reinsert)
+        .addRows(insert)
+        .addDeletes(eqDelete)
+        .toBranch(STAGING_BRANCH)
+        .commit();
+
+    File insertFile = new File(insert.location().replace("file:", ""));
+    File hiddenFile = new File(insertFile.getPath() + ".hidden");
+    assertThat(insertFile.renameTo(hiddenFile)).isTrue();
+
+    appendConvertTask(STAGING_BRANCH);
+
+    JobClient jobClient = null;
+    try {
+      jobClient = infra.env().executeAsync();
+
+      // The first cycle indexes the re-insert, fails on the unreadable file 
and does not commit.
+      long time1 = System.currentTimeMillis();
+      infra.source().sendRecord(Trigger.create(time1, 0), time1);
+      
assertThat(infra.sink().poll(Duration.ofSeconds(10)).success()).isFalse();
+      table.refresh();
+      assertThat(table.snapshot(SnapshotRef.MAIN_BRANCH)).isNull();
+
+      assertThat(hiddenFile.renameTo(insertFile)).isTrue();
+
+      // The retry must not delete the re-insert indexed by the first cycle.
+      long time2 = time1 + 1;
+      infra.source().sendRecord(Trigger.create(time2, 0), time2);
+      assertThat(infra.sink().poll(Duration.ofSeconds(10)).success()).isTrue();
+
+      assertRecords(table, ImmutableList.of(createRecord(1, "a"), 
createRecord(2, "b")));
+    } finally {
+      closeJobClient(jobClient);
+    }
+  }
+
   private void appendConvertTask() {
     appendConvertTask(STAGING_BRANCH);
   }
diff --git 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestConvertEqualityDeletesE2E.java
 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestConvertEqualityDeletesE2E.java
index e39d05b66f..d18f81a54a 100644
--- 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestConvertEqualityDeletesE2E.java
+++ 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/api/TestConvertEqualityDeletesE2E.java
@@ -20,6 +20,7 @@ package org.apache.iceberg.flink.maintenance.api;
 
 import static org.apache.iceberg.flink.SimpleDataUtil.createRecord;
 import static 
org.apache.iceberg.flink.maintenance.api.TableMaintenance.LOCK_REMOVER_OPERATOR_NAME;
+import static 
org.apache.iceberg.flink.maintenance.operator.EqualityConvertCommitter.COMMITTED_STAGING_SNAPSHOT_PROPERTY;
 import static 
org.apache.iceberg.flink.maintenance.operator.TableMaintenanceMetrics.SUCCEEDED_TASK_COUNTER;
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -30,12 +31,18 @@ import java.nio.file.Path;
 import java.time.Duration;
 import java.util.Collections;
 import java.util.List;
+import org.apache.flink.api.common.JobID;
 import org.apache.flink.api.common.functions.MapFunction;
 import org.apache.flink.api.common.typeinfo.TypeInformation;
 import org.apache.flink.api.java.typeutils.ResultTypeQueryable;
+import org.apache.flink.client.program.ClusterClient;
 import org.apache.flink.core.execution.JobClient;
+import org.apache.flink.core.execution.SavepointFormatType;
+import org.apache.flink.runtime.jobgraph.SavepointRestoreSettings;
 import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.streaming.api.graph.StreamGraph;
+import org.apache.flink.test.junit5.InjectClusterClient;
 import org.apache.iceberg.DataFile;
 import org.apache.iceberg.DeleteFile;
 import org.apache.iceberg.FileContent;
@@ -104,18 +111,7 @@ class TestConvertEqualityDeletesE2E extends 
OperatorTestBase {
     table.refresh();
     assertThat(dvCountOnMain(table)).isZero();
 
-    TableMaintenance.forTable(env, tableLoader(), LOCK_FACTORY)
-        .uidSuffix("ConvertEqualityDeletesE2EUID-" + stagingBranch)
-        .rateLimit(Duration.ofMillis(50))
-        .lockCheckDelay(Duration.ofMillis(50))
-        .add(
-            ConvertEqualityDeletes.builder()
-                .scheduleOnInterval(Duration.ofMillis(100))
-                .stagingBranch(stagingBranch)
-                .targetBranch(SnapshotRef.MAIN_BRANCH)
-                .equalityFieldColumns(ImmutableList.of("id", "data"))
-                .parallelism(2))
-        .append();
+    appendConvertTask(env, stagingBranch, "ConvertEqualityDeletesE2EUID-" + 
stagingBranch);
 
     JobClient jobClient = null;
     try {
@@ -211,6 +207,97 @@ class TestConvertEqualityDeletesE2E extends 
OperatorTestBase {
     }
   }
 
+  /**
+   * Restoring from a savepoint taken before a committed cycle leaves the 
converter's index without
+   * the rows that cycle copied to the target. The next conversion must 
rebuild it, or a delete for
+   * one of those rows resolves to nothing.
+   */
+  @Test
+  void reindexesAfterRestoreFromBeforeCommittedCycle(
+      @TempDir File savepointDir, @InjectClusterClient ClusterClient<?> 
clusterClient)
+      throws Exception {
+    Table table = createTableWithDelete(3);
+    insert(table, 1, "a");
+    table.manageSnapshots().createBranch(STAGING_BRANCH).commit();
+    table
+        .newRowDelta()
+        .addDeletes(writeEqualityDelete(table, 1, "a"))
+        .toBranch(STAGING_BRANCH)
+        .commit();
+    table.refresh();
+
+    appendConvertTask(env, STAGING_BRANCH, "ConvertEqualityDeletesRestoreUID");
+    String savepointPath;
+    JobClient jobClient = null;
+    try {
+      jobClient = env.executeAsync();
+      awaitConverted(table, table.snapshot(STAGING_BRANCH).snapshotId());
+      savepointPath =
+          jobClient.triggerSavepoint(savepointDir.getPath(), 
SavepointFormatType.CANONICAL).get();
+
+      // This cycle copies S2's row to the target after the savepoint.
+      table.newAppend().appendFile(writeDataFile(table, 2, 
"b")).toBranch(STAGING_BRANCH).commit();
+      table.refresh();
+      awaitConverted(table, table.snapshot(STAGING_BRANCH).snapshotId());
+    } finally {
+      closeJobClient(jobClient);
+    }
+
+    table
+        .newRowDelta()
+        .addDeletes(writeEqualityDelete(table, 2, "b"))
+        .toBranch(STAGING_BRANCH)
+        .commit();
+    table.refresh();
+    long deleteSnapshotId = table.snapshot(STAGING_BRANCH).snapshotId();
+
+    StreamExecutionEnvironment restoredEnv = 
StreamExecutionEnvironment.getExecutionEnvironment();
+    appendConvertTask(restoredEnv, STAGING_BRANCH, 
"ConvertEqualityDeletesRestoreUID");
+    StreamGraph streamGraph = restoredEnv.getStreamGraph();
+    // Submitting the graph directly skips the executor, which is what 
normally assigns the job id
+    streamGraph.setJobId(JobID.generate());
+    
streamGraph.setSavepointRestoreSettings(SavepointRestoreSettings.forPath(savepointPath));
+    JobID restoredJobId = clusterClient.submitJob(streamGraph).get();
+    try {
+      awaitConverted(table, deleteSnapshotId);
+      SimpleDataUtil.assertTableRecords(table, ImmutableList.of());
+    } finally {
+      clusterClient.cancel(restoredJobId).get();
+      Awaitility.await()
+          .until(() -> 
clusterClient.getJobStatus(restoredJobId).get().isTerminalState());
+    }
+  }
+
+  private void appendConvertTask(
+      StreamExecutionEnvironment environment, String stagingBranch, String 
uidSuffix)
+      throws IOException {
+    TableMaintenance.forTable(environment, tableLoader(), LOCK_FACTORY)
+        .uidSuffix(uidSuffix)
+        .rateLimit(Duration.ofMillis(50))
+        .lockCheckDelay(Duration.ofMillis(50))
+        .add(
+            ConvertEqualityDeletes.builder()
+                .scheduleOnInterval(Duration.ofMillis(100))
+                .stagingBranch(stagingBranch)
+                .targetBranch(SnapshotRef.MAIN_BRANCH)
+                .equalityFieldColumns(ImmutableList.of("id", "data"))
+                .parallelism(2))
+        .append();
+  }
+
+  private static void awaitConverted(Table table, long stagingSnapshotId) {
+    Awaitility.await()
+        .atMost(Duration.ofMinutes(5))
+        .pollInterval(Duration.ofMillis(200))
+        .untilAsserted(
+            () -> {
+              table.refresh();
+              assertThat(table.currentSnapshot().summary())
+                  .containsEntry(
+                      COMMITTED_STAGING_SNAPSHOT_PROPERTY, 
String.valueOf(stagingSnapshotId));
+            });
+  }
+
   private static void awaitSucceeded(List<String> counterKey, long expected) {
     Awaitility.await()
         .atMost(Duration.ofMinutes(3))
diff --git 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestEqualityConvertCommitter.java
 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestEqualityConvertCommitter.java
index 35aa9908bc..e52f53f942 100644
--- 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestEqualityConvertCommitter.java
+++ 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestEqualityConvertCommitter.java
@@ -25,6 +25,7 @@ import java.io.IOException;
 import java.nio.file.Path;
 import java.util.List;
 import java.util.stream.Collectors;
+import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
 import org.apache.flink.streaming.api.watermark.Watermark;
 import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
 import org.apache.flink.streaming.util.TwoInputStreamOperatorTestHarness;
@@ -54,6 +55,8 @@ import org.junit.jupiter.api.io.TempDir;
 
 class TestEqualityConvertCommitter extends OperatorTestBase {
 
+  private static final long INDEX_GENERATION = 1L;
+
   @TempDir private Path tempDir;
 
   @Test
@@ -78,7 +81,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase {
               stagingSnapshotId,
               snapshotIdBefore,
               doneTs - 1,
-              doneTs);
+              doneTs,
+              INDEX_GENERATION);
 
       harness.processElement2(new StreamRecord<>(planResult, doneTs - 1));
       harness.processBothWatermarks(new Watermark(doneTs));
@@ -116,7 +120,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase 
{
               42L,
               snapshotIdBefore,
               doneTs - 2,
-              doneTs);
+              doneTs,
+              INDEX_GENERATION);
 
       harness.processElement2(new StreamRecord<>(planResult, doneTs - 2));
 
@@ -165,7 +170,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase 
{
               42L,
               snapshotIdBefore,
               triggerTs,
-              doneTs);
+              doneTs,
+              INDEX_GENERATION);
       harness.processElement2(new StreamRecord<>(planResult));
       harness.processBothWatermarks(new Watermark(doneTs));
 
@@ -203,7 +209,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase 
{
       harness.open();
 
       long doneTs = System.currentTimeMillis();
-      EqualityConvertPlan planResult = EqualityConvertPlan.noOp(null, doneTs - 
1, doneTs);
+      EqualityConvertPlan planResult =
+          EqualityConvertPlan.noOp(null, doneTs - 1, doneTs, INDEX_GENERATION);
 
       harness.processElement2(new StreamRecord<>(planResult, doneTs - 1));
       harness.processBothWatermarks(new Watermark(doneTs));
@@ -235,7 +242,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase 
{
               42L,
               snapshotIdBefore,
               doneTs - 1,
-              doneTs);
+              doneTs,
+              INDEX_GENERATION);
 
       // Receive an abort signal from the DVWriter followed by the planning 
planResult.
       harness.processElement1(new StreamRecord<>(DVWriteResult.ABORT, doneTs));
@@ -250,6 +258,129 @@ class TestEqualityConvertCommitter extends 
OperatorTestBase {
     }
   }
 
+  @Test
+  void withholdsNextConversionCommitAfterFailedIdleCycle() throws Exception {
+    Table table = createTable(3, FileFormat.PARQUET);
+    insert(table, 1, "a");
+
+    DataFile stagingDataFile = getFirstDataFile(table);
+    long mainSnapshotId = table.currentSnapshot().snapshotId();
+    DeleteFile writtenDv = writeDV(table, stagingDataFile.location(), 0L);
+
+    try (TwoInputStreamOperatorTestHarness<DVWriteResult, EqualityConvertPlan, 
Trigger> harness =
+        createHarness()) {
+      harness.open();
+
+      failIdleRebuild(harness, mainSnapshotId);
+
+      // The next conversion is not committed, and the DV it wrote is deleted.
+      harness.processElement1(
+          new StreamRecord<>(
+              new DVWriteResult(Lists.newArrayList(writtenDv), 
Lists.newArrayList()), 20L));
+      harness.processElement2(
+          new StreamRecord<>(conversionPlan(stagingDataFile, mainSnapshotId, 
20L, 1L), 19L));
+      harness.processBothWatermarks(new Watermark(20L));
+
+      table.refresh();
+      
assertThat(table.currentSnapshot().snapshotId()).isEqualTo(mainSnapshotId);
+      
assertThat(table.io().newInputFile(writtenDv.location()).exists()).isFalse();
+      
assertThat(harness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull();
+
+      // Planned again with a rebuilt index on a new generation, the 
conversion commits.
+      harness.processElement2(
+          new StreamRecord<>(conversionPlan(stagingDataFile, mainSnapshotId, 
30L, 2L), 29L));
+      harness.processBothWatermarks(new Watermark(30L));
+
+      assertThat(committedStagingSnapshot(table)).isEqualTo("42");
+    }
+  }
+
+  @Test
+  void withholdsNextConversionCommitAfterRestore() throws Exception {
+    Table table = createTable(3, FileFormat.PARQUET);
+    insert(table, 1, "a");
+
+    DataFile stagingDataFile = getFirstDataFile(table);
+    long mainSnapshotId = table.currentSnapshot().snapshotId();
+
+    OperatorSubtaskState state;
+    try (TwoInputStreamOperatorTestHarness<DVWriteResult, EqualityConvertPlan, 
Trigger> harness =
+        createHarness()) {
+      harness.open();
+
+      failIdleRebuild(harness, mainSnapshotId);
+
+      state = harness.snapshot(1, 11L);
+    }
+
+    try (TwoInputStreamOperatorTestHarness<DVWriteResult, EqualityConvertPlan, 
Trigger> harness =
+        createHarness()) {
+      harness.initializeState(state);
+      harness.open();
+
+      harness.processElement2(
+          new StreamRecord<>(conversionPlan(stagingDataFile, mainSnapshotId, 
20L, 1L), 19L));
+      harness.processBothWatermarks(new Watermark(20L));
+
+      table.refresh();
+      
assertThat(table.currentSnapshot().snapshotId()).isEqualTo(mainSnapshotId);
+      
assertThat(harness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).isNull();
+    }
+  }
+
+  @Test
+  void commitsReplannedConversionAfterFailedConversion() throws Exception {
+    Table table = createTable(3, FileFormat.PARQUET);
+    insert(table, 1, "a");
+
+    DataFile stagingDataFile = getFirstDataFile(table);
+    long mainSnapshotId = table.currentSnapshot().snapshotId();
+
+    try (TwoInputStreamOperatorTestHarness<DVWriteResult, EqualityConvertPlan, 
Trigger> harness =
+        createHarness()) {
+      harness.open();
+
+      // A failed conversion is planned again with a rebuilt index, so nothing 
is withheld.
+      harness.processElement1(new StreamRecord<>(DVWriteResult.ABORT, 10L));
+      harness.processElement2(
+          new StreamRecord<>(conversionPlan(stagingDataFile, mainSnapshotId, 
10L, 1L), 9L));
+      harness.processBothWatermarks(new Watermark(10L));
+
+      harness.processElement2(
+          new StreamRecord<>(conversionPlan(stagingDataFile, mainSnapshotId, 
20L, 2L), 19L));
+      harness.processBothWatermarks(new Watermark(20L));
+
+      assertThat(committedStagingSnapshot(table)).isEqualTo("42");
+    }
+  }
+
+  @Test
+  void commitsNextConversionAfterCleanRebuild() throws Exception {
+    Table table = createTable(3, FileFormat.PARQUET);
+    insert(table, 1, "a");
+
+    DataFile stagingDataFile = getFirstDataFile(table);
+    long mainSnapshotId = table.currentSnapshot().snapshotId();
+
+    try (TwoInputStreamOperatorTestHarness<DVWriteResult, EqualityConvertPlan, 
Trigger> harness =
+        createHarness()) {
+      harness.open();
+
+      failIdleRebuild(harness, mainSnapshotId);
+
+      // An external commit makes the next idle cycle rebuild the index, which 
repairs the failure.
+      harness.processElement2(
+          new StreamRecord<>(EqualityConvertPlan.noOp(mainSnapshotId, 19L, 
20L, 2L), 19L));
+      harness.processBothWatermarks(new Watermark(20L));
+
+      harness.processElement2(
+          new StreamRecord<>(conversionPlan(stagingDataFile, mainSnapshotId, 
30L, 2L), 29L));
+      harness.processBothWatermarks(new Watermark(30L));
+
+      assertThat(committedStagingSnapshot(table)).isEqualTo("42");
+    }
+  }
+
   @Test
   void failsCommitWhenExternalCommitLandsAfterPlan() throws Exception {
     Table table = createTable(3, FileFormat.PARQUET);
@@ -272,7 +403,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase 
{
               stagingSnapshotId,
               mainSnapshotIdAtPlan,
               doneTs - 1,
-              doneTs);
+              doneTs,
+              INDEX_GENERATION);
 
       harness.processElement2(new StreamRecord<>(planResult, doneTs - 1));
 
@@ -330,7 +462,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase 
{
               stagingSnapshotId,
               mainSnapshotIdAtPlan,
               doneTs - 1,
-              doneTs);
+              doneTs,
+              INDEX_GENERATION);
 
       // Cycle 1 commits, advancing the target past mainSnapshotIdAtPlan and 
writing the marker.
       harness.processElement2(new StreamRecord<>(planResult, doneTs - 1));
@@ -353,7 +486,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase 
{
               stagingSnapshotId,
               mainSnapshotIdAtPlan,
               doneTs2 - 1,
-              doneTs2);
+              doneTs2,
+              INDEX_GENERATION);
 
       harness.processElement2(new StreamRecord<>(replay, doneTs2 - 1));
       harness.processBothWatermarks(new Watermark(doneTs2));
@@ -399,7 +533,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase 
{
               777L,
               mainSnapshotIdAtPlan,
               doneTs - 1,
-              doneTs);
+              doneTs,
+              INDEX_GENERATION);
 
       harness.processElement1(
           new StreamRecord<>(
@@ -450,7 +585,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase 
{
               42L,
               snapshotIdBefore,
               doneTs - 1,
-              doneTs);
+              doneTs,
+              INDEX_GENERATION);
 
       // One writer task wrote a DV (rewriting an existing one); a sibling 
task aborted.
       harness.processElement1(
@@ -505,7 +641,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase 
{
               888L,
               mainSnapshotIdAtPlan,
               doneTs - 1,
-              doneTs);
+              doneTs,
+              INDEX_GENERATION);
 
       harness.processElement1(
           new StreamRecord<>(
@@ -557,7 +694,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase 
{
               123L,
               mainSnapshotId,
               doneTs - 1,
-              doneTs);
+              doneTs,
+              INDEX_GENERATION);
 
       harness.processElement1(
           new StreamRecord<>(
@@ -616,7 +754,8 @@ class TestEqualityConvertCommitter extends OperatorTestBase 
{
               123L,
               mainSnapshotId,
               doneTs - 1,
-              doneTs);
+              doneTs,
+              INDEX_GENERATION);
 
       harness.processElement1(
           new StreamRecord<>(
@@ -655,6 +794,39 @@ class TestEqualityConvertCommitter extends 
OperatorTestBase {
             SnapshotRef.MAIN_BRANCH));
   }
 
+  // The planner rebuilt the index to generation 1 in a cycle with nothing to 
convert, and a worker
+  // failed to update it.
+  private static void failIdleRebuild(
+      TwoInputStreamOperatorTestHarness<DVWriteResult, EqualityConvertPlan, 
Trigger> harness,
+      long mainSnapshotId)
+      throws Exception {
+    harness.processElement1(new StreamRecord<>(DVWriteResult.ABORT, 10L));
+    harness.processElement2(
+        new StreamRecord<>(EqualityConvertPlan.noOp(mainSnapshotId, 9L, 10L, 
1L), 9L));
+    harness.processBothWatermarks(new Watermark(10L));
+  }
+
+  private static String committedStagingSnapshot(Table table) {
+    table.refresh();
+    return table
+        .currentSnapshot()
+        .summary()
+        .get(EqualityConvertCommitter.COMMITTED_STAGING_SNAPSHOT_PROPERTY);
+  }
+
+  private static EqualityConvertPlan conversionPlan(
+      DataFile stagingDataFile, long mainSnapshotId, long doneTs, long 
indexGeneration) {
+    return new EqualityConvertPlan(
+        Lists.newArrayList(stagingDataFile),
+        Lists.newArrayList(),
+        Lists.newArrayList(),
+        42L,
+        mainSnapshotId,
+        doneTs - 1,
+        doneTs,
+        indexGeneration);
+  }
+
   private static List<Watermark> 
watermarks(TwoInputStreamOperatorTestHarness<?, ?, ?> harness) {
     return harness.getOutput().stream()
         .filter(Watermark.class::isInstance)
diff --git 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestEqualityConvertDVWriter.java
 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestEqualityConvertDVWriter.java
index 93614fe8ab..b7dd1e308d 100644
--- 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestEqualityConvertDVWriter.java
+++ 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestEqualityConvertDVWriter.java
@@ -40,6 +40,7 @@ import org.junit.jupiter.api.Test;
 class TestEqualityConvertDVWriter extends OperatorTestBase {
 
   private static final byte[] EMPTY_PARTITION = new byte[0];
+  private static final long INDEX_GENERATION = 1L;
 
   @Test
   void writesDVFileForSinglePosition() throws Exception {
@@ -247,7 +248,8 @@ class TestEqualityConvertDVWriter extends OperatorTestBase {
               1L,
               null,
               0L,
-              0L);
+              0L,
+              INDEX_GENERATION);
 
       // New position 1 in the same file. Must merge it with the staged 
position 0.
       harness.processElement1(
@@ -288,7 +290,8 @@ class TestEqualityConvertDVWriter extends OperatorTestBase {
               1L,
               plannedSnapshotId,
               0L,
-              0L);
+              0L,
+              INDEX_GENERATION);
 
       harness.processElement1(
           new StreamRecord<>(new DVPosition(dataFilePath, 0, 0, 
EMPTY_PARTITION, 0L), time));
@@ -491,7 +494,14 @@ class TestEqualityConvertDVWriter extends OperatorTestBase 
{
 
   private static EqualityConvertPlan emptyEqualityConvertPlan() {
     return new EqualityConvertPlan(
-        Lists.newArrayList(), Lists.newArrayList(), Lists.newArrayList(), 1L, 
null, 0L, 0L);
+        Lists.newArrayList(),
+        Lists.newArrayList(),
+        Lists.newArrayList(),
+        1L,
+        null,
+        0L,
+        0L,
+        INDEX_GENERATION);
   }
 
   private static String getFirstDataFilePath(Table table) {
diff --git 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestEqualityConvertPlanner.java
 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestEqualityConvertPlanner.java
index 1482ce8726..c98de623e7 100644
--- 
a/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestEqualityConvertPlanner.java
+++ 
b/flink/v2.3/flink/src/test/java/org/apache/iceberg/flink/maintenance/operator/TestEqualityConvertPlanner.java
@@ -26,9 +26,13 @@ import java.io.File;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
 import org.apache.flink.runtime.checkpoint.OperatorSubtaskState;
+import org.apache.flink.runtime.checkpoint.StateObjectCollection;
+import org.apache.flink.runtime.state.OperatorStateHandle;
+import org.apache.flink.runtime.state.OperatorStreamStateHandle;
 import org.apache.flink.streaming.api.watermark.Watermark;
 import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
 import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness;
@@ -45,6 +49,7 @@ import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.RewriteFiles;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.SnapshotRef;
+import org.apache.iceberg.SnapshotSummary;
 import org.apache.iceberg.Table;
 import org.apache.iceberg.data.FileHelpers;
 import org.apache.iceberg.data.GenericAppenderHelper;
@@ -53,9 +58,12 @@ import org.apache.iceberg.data.Record;
 import org.apache.iceberg.deletes.PositionDelete;
 import org.apache.iceberg.flink.maintenance.api.Trigger;
 import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
 import org.apache.iceberg.relocated.com.google.common.collect.Sets;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 class TestEqualityConvertPlanner extends OperatorTestBase {
 
@@ -353,6 +361,13 @@ class TestEqualityConvertPlanner extends OperatorTestBase {
       sendTrigger(harness);
       assertThat(harness.extractOutputValues()).hasSize(firstTriggerCount);
       assertThat(planner(harness).skippedNoOpCycles()).isEqualTo(1);
+
+      // Each trigger emits a cycle start marker followed by the plan. The 
bootstrap built
+      // generation 1, and the second trigger reused it.
+      List<StreamRecord<EqualityConvertPlan>> metadata =
+          
Lists.newArrayList(harness.getSideOutput(EqualityConvertPlanner.METADATA_STREAM));
+      assertThat(metadata.get(1).getValue().indexGeneration()).isEqualTo(1L);
+      assertThat(metadata.get(3).getValue().indexGeneration()).isEqualTo(1L);
     }
   }
 
@@ -668,6 +683,38 @@ class TestEqualityConvertPlanner extends OperatorTestBase {
     }
   }
 
+  @Test
+  void startsNewIndexGenerationWhenReplanningOnEmptyTarget() throws Exception {
+    Table table = createTableWithDelete(3);
+
+    // The target branch has no snapshot until the first conversion commit.
+    table.manageSnapshots().createBranch(STAGING_BRANCH).commit();
+    table.refresh();
+
+    DataFile reinsert = writeDataFile(table, createRecord(1, "a"));
+    DeleteFile eqDelete = writeEqualityDelete(table, 1, "a");
+    
table.newRowDelta().addRows(reinsert).addDeletes(eqDelete).toBranch(STAGING_BRANCH).commit();
+    table.refresh();
+
+    try (OneInputStreamOperatorTestHarness<Trigger, ReadCommand> harness =
+        createHarness(STAGING_BRANCH)) {
+      harness.open();
+
+      // The first cycle indexes the re-insert but does not commit, so the 
same staging snapshot is
+      // planned again. Its eq delete must not resolve against the re-insert 
of the first cycle.
+      sendTrigger(harness);
+      sendTrigger(harness);
+
+      List<ReadCommand> eqDeletes =
+          harness.extractOutputValues().stream()
+              .filter(cmd -> cmd.task() instanceof EqualityDeleteFileScanTask)
+              .collect(Collectors.toList());
+      assertThat(eqDeletes).hasSize(2);
+      assertThat(eqDeletes.get(1).indexGeneration())
+          .isGreaterThan(eqDeletes.get(0).indexGeneration());
+    }
+  }
+
   @Test
   void rebuildsIndexAfterRestoreOfUncommittedCycle() throws Exception {
     Table table = createTableWithDelete(3);
@@ -687,8 +734,7 @@ class TestEqualityConvertPlanner extends OperatorTestBase {
       harness.open();
 
       // First cycle converts S1 and commits, so the newest commit on the 
target is the converter's
-      // own marker. The existing reindex path counts only commits newer than 
that marker, so it
-      // cannot fire on the restore below and the replan check is the only 
thing that can rebuild.
+      // own marker, which the planner sees on the next trigger.
       sendTrigger(harness);
       assertThat(harness.extractOutputValues()).hasSize(3);
       simulateConvertCommit(table, 
table.snapshot(STAGING_BRANCH).snapshotId());
@@ -722,6 +768,206 @@ class TestEqualityConvertPlanner extends OperatorTestBase 
{
     }
   }
 
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void rebuildsIndexAfterRestoreFromBeforeCommittedCycle(boolean 
plannerSawConversionCommit)
+      throws Exception {
+    Table table = createTableWithDelete(3);
+    insert(table, 1, "a");
+
+    table.manageSnapshots().createBranch(STAGING_BRANCH).commit();
+    table.refresh();
+
+    DeleteFile eqDelete = writeEqualityDelete(table, 1, "a");
+    table.newRowDelta().addDeletes(eqDelete).toBranch(STAGING_BRANCH).commit();
+    table.refresh();
+
+    DataFile stagingData;
+    OperatorSubtaskState state;
+    try (OneInputStreamOperatorTestHarness<Trigger, ReadCommand> harness =
+        createHarness(STAGING_BRANCH)) {
+      harness.open();
+
+      // The checkpoint follows the first cycle, which converts S1 and 
commits. The planner sees
+      // that commit only if another trigger follows.
+      sendTrigger(harness);
+      simulateConvertCommit(table, 
table.snapshot(STAGING_BRANCH).snapshotId());
+      if (plannerSawConversionCommit) {
+        sendTrigger(harness);
+      }
+
+      state = harness.snapshot(1, System.currentTimeMillis());
+
+      // The second cycle indexes S2's data file as staging data and copies it 
to the target. No
+      // checkpoint follows it.
+      stagingData = writeDataFile(table, createRecord(2, "b"));
+      
table.newAppend().appendFile(stagingData).toBranch(STAGING_BRANCH).commit();
+      table.refresh();
+      long s2SnapshotId = table.snapshot(STAGING_BRANCH).snapshotId();
+
+      sendTrigger(harness);
+      table
+          .newRowDelta()
+          .addRows(stagingData)
+          .set(
+              EqualityConvertCommitter.COMMITTED_STAGING_SNAPSHOT_PROPERTY,
+              String.valueOf(s2SnapshotId))
+          .commit();
+      table.refresh();
+    }
+
+    DeleteFile eqDelete2 = writeEqualityDelete(table, 2, "b");
+    
table.newRowDelta().addDeletes(eqDelete2).toBranch(STAGING_BRANCH).commit();
+    table.refresh();
+
+    try (OneInputStreamOperatorTestHarness<Trigger, ReadCommand> harness =
+        createHarness(STAGING_BRANCH)) {
+      harness.initializeState(state);
+      harness.open();
+
+      // The index restored with the planner never held S2's row, so the 
planner rebuilds it from
+      // the target before resolving the delete for that row.
+      sendTrigger(harness);
+      List<ReadCommand> commands = harness.extractOutputValues();
+
+      assertThat(commands).anyMatch(cmd -> 
filePath(cmd).equals(stagingData.location()));
+      assertThat(countEqDeleteTasks(commands)).isEqualTo(1);
+      assertThat(planner(harness).reindexCount()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  void rebuildsIndexAfterRestoreWhenExternalCommitIsBelowConversionCommit() 
throws Exception {
+    Table table = createTableWithDelete(3);
+    insert(table, 1, "a");
+
+    table.manageSnapshots().createBranch(STAGING_BRANCH).commit();
+    table.refresh();
+
+    DeleteFile eqDelete = writeEqualityDelete(table, 1, "a");
+    table.newRowDelta().addDeletes(eqDelete).toBranch(STAGING_BRANCH).commit();
+    table.refresh();
+    long s1SnapshotId = table.snapshot(STAGING_BRANCH).snapshotId();
+
+    OperatorSubtaskState state;
+    try (OneInputStreamOperatorTestHarness<Trigger, ReadCommand> harness =
+        createHarness(STAGING_BRANCH)) {
+      harness.open();
+
+      // The first cycle plans S1 but does not commit, then a checkpoint is 
taken.
+      sendTrigger(harness);
+      state = harness.snapshot(1, System.currentTimeMillis());
+
+      // An external commit lands, the planner indexes it and the second cycle 
commits S1 on top.
+      insert(table, 2, "b");
+      sendTrigger(harness);
+      assertThat(planner(harness).reindexCount()).isEqualTo(1);
+      simulateConvertCommit(table, s1SnapshotId);
+    }
+
+    try (OneInputStreamOperatorTestHarness<Trigger, ReadCommand> harness =
+        createHarness(STAGING_BRANCH)) {
+      harness.initializeState(state);
+      harness.open();
+
+      // The newest conversion commit is for the planned snapshot, but the 
external commit below it
+      // is not in the restored index.
+      sendTrigger(harness);
+
+      // Two inserts plus simulateConvertCommit's marker file = 3 data files 
on the target.
+      
assertThat(countDataFileTasks(harness.extractOutputValues())).isEqualTo(3);
+      assertThat(planner(harness).reindexCount()).isEqualTo(1);
+    }
+  }
+
+  @Test
+  void rebuildsIndexAfterRestoreFromFailedRebuild() throws Exception {
+    Table table = createTableWithDelete(3);
+    insert(table, 1, "a");
+
+    table.manageSnapshots().createBranch(STAGING_BRANCH).commit();
+    table.refresh();
+
+    DeleteFile eqDelete = writeEqualityDelete(table, 1, "a");
+    table.newRowDelta().addDeletes(eqDelete).toBranch(STAGING_BRANCH).commit();
+    table.refresh();
+    long s1SnapshotId = table.snapshot(STAGING_BRANCH).snapshotId();
+
+    List<File> manifests = dataManifestFiles(table);
+
+    OperatorSubtaskState state;
+    try (OneInputStreamOperatorTestHarness<Trigger, ReadCommand> harness =
+        createHarness(STAGING_BRANCH)) {
+      harness.open();
+
+      // The first cycle plans S1 but its commit fails.
+      sendTrigger(harness);
+
+      // The retry's rebuild fails part way, then a checkpoint is taken.
+      hide(manifests);
+      sendTrigger(harness);
+      
assertThat(harness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).hasSize(1);
+      restore(manifests);
+      state = harness.snapshot(1, System.currentTimeMillis());
+
+      // The next retry rebuilds the index and commits S1 on the same parent.
+      sendTrigger(harness);
+      simulateConvertCommit(table, s1SnapshotId);
+    }
+
+    try (OneInputStreamOperatorTestHarness<Trigger, ReadCommand> harness =
+        createHarness(STAGING_BRANCH)) {
+      harness.initializeState(state);
+      harness.open();
+
+      // The commit has the planned snapshot and parent, but the restored 
index is the one the
+      // failed rebuild left.
+      sendTrigger(harness);
+
+      // One insert plus simulateConvertCommit's marker file = 2 data files on 
the target.
+      
assertThat(countDataFileTasks(harness.extractOutputValues())).isEqualTo(2);
+    }
+  }
+
+  @Test
+  void bootstrapsAgainAfterRestoreFromFailedBootstrap() throws Exception {
+    Table table = createTableWithDelete(3);
+    insert(table, 1, "a");
+
+    table.manageSnapshots().createBranch(STAGING_BRANCH).commit();
+    table.refresh();
+
+    // The newest commit is a conversion commit, so no external commit makes 
the planner rebuild.
+    simulateConvertCommit(table, table.snapshot(STAGING_BRANCH).snapshotId());
+
+    List<File> manifests = dataManifestFiles(table);
+
+    OperatorSubtaskState state;
+    try (OneInputStreamOperatorTestHarness<Trigger, ReadCommand> harness =
+        createHarness(STAGING_BRANCH)) {
+      harness.open();
+
+      hide(manifests);
+      sendTrigger(harness);
+      
assertThat(harness.getSideOutput(TaskResultAggregator.ERROR_STREAM)).hasSize(1);
+      state = harness.snapshot(1, System.currentTimeMillis());
+    }
+
+    restore(manifests);
+
+    try (OneInputStreamOperatorTestHarness<Trigger, ReadCommand> harness =
+        createHarness(STAGING_BRANCH)) {
+      harness.initializeState(state);
+      harness.open();
+
+      // The checkpoint must not claim the index covers the head: the 
bootstrap did not finish.
+      sendTrigger(harness);
+
+      // One insert plus simulateConvertCommit's marker file = 2 data files on 
the target.
+      
assertThat(countDataFileTasks(harness.extractOutputValues())).isEqualTo(2);
+    }
+  }
+
   @Test
   void noMainReEmitAfterOwnCommit() throws Exception {
     Table table = createTableWithDelete(3);
@@ -963,6 +1209,55 @@ class TestEqualityConvertPlanner extends OperatorTestBase 
{
     }
   }
 
+  @Test
+  void rebuildsIndexAfterRestoreFromOlderVersion() throws Exception {
+    Table table = createTableWithDelete(3);
+    insert(table, 1, "a");
+
+    table.manageSnapshots().createBranch(STAGING_BRANCH).commit();
+    table.refresh();
+
+    DeleteFile eqDelete = writeEqualityDelete(table, 1, "a");
+    table.newRowDelta().addDeletes(eqDelete).toBranch(STAGING_BRANCH).commit();
+    table.refresh();
+
+    OperatorSubtaskState state;
+    long generationBeforeRestore;
+    try (OneInputStreamOperatorTestHarness<Trigger, ReadCommand> harness =
+        createHarness(STAGING_BRANCH)) {
+      harness.open();
+
+      // The same state as in stateRestoredAfterCheckpoint, where the index is 
reused.
+      sendTrigger(harness);
+      simulateConvertCommit(table, 
table.snapshot(STAGING_BRANCH).snapshotId());
+      sendTrigger(harness);
+      generationBeforeRestore =
+          harness.extractOutputValues().stream()
+              .mapToLong(ReadCommand::indexGeneration)
+              .max()
+              .getAsLong();
+      state = harness.snapshot(1, System.currentTimeMillis());
+    }
+
+    try (OneInputStreamOperatorTestHarness<Trigger, ReadCommand> harness =
+        createHarness(STAGING_BRANCH)) {
+      harness.initializeState(stateOfOlderVersion(state));
+      harness.open();
+
+      // An older version can record the head as indexed while the index is 
incomplete.
+      sendTrigger(harness);
+      List<ReadCommand> commands = harness.extractOutputValues();
+
+      long dataFileCount =
+          Long.parseLong(
+              
table.currentSnapshot().summary().get(SnapshotSummary.TOTAL_DATA_FILES_PROP));
+      assertThat(countDataFileTasks(commands)).isEqualTo(dataFileCount);
+
+      // The workers ignore index entries of an older generation.
+      assertThat(commands).allMatch(cmd -> cmd.indexGeneration() > 
generationBeforeRestore);
+    }
+  }
+
   @Test
   void failsOnV2PosDeleteOnStagingBranch() throws Exception {
     Table table = createTableWithDelete(2);
@@ -1175,10 +1470,49 @@ class TestEqualityConvertPlanner extends 
OperatorTestBase {
         3);
   }
 
+  private static List<File> dataManifestFiles(Table table) {
+    return table.currentSnapshot().dataManifests(table.io()).stream()
+        .map(manifest -> new File(manifest.path().replace("file:", "")))
+        .collect(Collectors.toList());
+  }
+
+  // Makes reads of the files fail until restore is called.
+  private static void hide(List<File> files) {
+    assertThat(files.stream().map(file -> 
file.renameTo(hidden(file))).collect(Collectors.toList()))
+        .containsOnly(true);
+  }
+
+  private static void restore(List<File> files) {
+    assertThat(files.stream().map(file -> 
hidden(file).renameTo(file)).collect(Collectors.toList()))
+        .containsOnly(true);
+  }
+
+  private static File hidden(File file) {
+    return new File(file.getPath() + ".hidden");
+  }
+
   private static long countDataFileTasks(List<ReadCommand> commands) {
     return commands.stream().filter(c -> c.task() instanceof 
FileScanTask).count();
   }
 
+  // Keeps the operator state entries that the version before the stateVersion 
entry wrote.
+  private static OperatorSubtaskState stateOfOlderVersion(OperatorSubtaskState 
state) {
+    Set<String> olderStateNames =
+        Sets.newHashSet(
+            "indexSnapshotId", "indexGeneration", "eqFieldIds", 
"pendingStagingSnapshotId");
+    List<OperatorStateHandle> handles = Lists.newArrayList();
+    for (OperatorStateHandle handle : state.getManagedOperatorState()) {
+      Map<String, OperatorStateHandle.StateMetaInfo> stateMetaInfos =
+          Maps.newHashMap(handle.getStateNameToPartitionOffsets());
+      stateMetaInfos.keySet().retainAll(olderStateNames);
+      handles.add(new OperatorStreamStateHandle(stateMetaInfos, 
handle.getDelegateStateHandle()));
+    }
+
+    return OperatorSubtaskState.builder()
+        .setManagedOperatorState(new StateObjectCollection<>(handles))
+        .build();
+  }
+
   private static long countEqDeleteTasks(List<ReadCommand> commands) {
     return 
commands.stream().filter(TestEqualityConvertPlanner::isEqDelete).count();
   }

Reply via email to