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 3c315c8df4 Flink: Forward watermarks in EqualityConvertCommitter with 
no active cycle (#18439)
3c315c8df4 is described below

commit 3c315c8df478713f12346df37b590e1e33a0f5e2
Author: Rakesh Pai <[email protected]>
AuthorDate: Tue Oct 20 05:41:45 2026 -0700

    Flink: Forward watermarks in EqualityConvertCommitter with no active cycle 
(#18439)
    
    Co-authored-by: Maximilian Michels <[email protected]>
    
    Fixes #18435.
---
 .../operator/EqualityConvertCommitter.java         |  11 +-
 .../maintenance/operator/EqualityConvertPlan.java  |  11 ++
 .../operator/EqualityConvertPlanner.java           |  11 +-
 .../api/TestConvertEqualityDeletesE2E.java         | 116 +++++++++++++++++++++
 .../operator/TestEqualityConvertCommitter.java     |  61 +++++++++++
 .../operator/TestEqualityConvertDVWriter.java      |  31 ++++++
 .../operator/TestEqualityConvertPlanner.java       |  19 ++--
 7 files changed, 250 insertions(+), 10 deletions(-)

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 ada16ec093..34d996c3d9 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
@@ -143,12 +143,21 @@ public class EqualityConvertCommitter extends 
AbstractStreamOperator<Trigger>
 
   @Override
   public void processElement2(StreamRecord<EqualityConvertPlan> record) {
+    // Includes the cycle-start marker. Its maximal done timestamp holds 
watermarks until the real
+    // plan replaces it.
     planResult = record.getValue();
   }
 
   @Override
   public void processWatermark(Watermark mark) throws Exception {
-    if (planResult == null || mark.getTimestamp() < 
planResult.doneTimestamp()) {
+    if (planResult == null) {
+      // No cycle in flight: the watermark comes from another task's trigger 
or a recovery trigger
+      // sharing the maintenance lock. Holding it back would keep that lock 
forever.
+      super.processWatermark(mark);
+      return;
+    }
+
+    if (mark.getTimestamp() < planResult.doneTimestamp()) {
       // Hold back watermarks until the cycle commits so the LockRemover keeps 
the maintenance lock
       // for the whole cycle. Forwarding the planner's mid-cycle phase 
watermarks will release the
       // lock early and could let the next trigger run a concurrent cycle on 
the same staging
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 191bf83220..4aa326d19a 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
@@ -34,6 +34,9 @@ import 
org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
  * #NO_OP_STAGING_SNAPSHOT_ID}. Use {@link #noOp(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.
+ *
  * @param dataFiles new staging data files committed in the cycle
  * @param stagingDVFiles staging DVs passed through to main, used by DVWriter 
to merge with
  *     newly-created DVs
@@ -78,4 +81,12 @@ public record EqualityConvertPlan(
         triggerTimestamp,
         doneTimestamp);
   }
+
+  /**
+   * Cycle-start marker: a no-op plan whose done timestamp is never reached, 
so consumers hold it
+   * until the cycle's actual plan replaces it.
+   */
+  public static EqualityConvertPlan cycleStart(long triggerTimestamp) {
+    return noOp(null, triggerTimestamp, Long.MAX_VALUE);
+  }
 }
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 82791bae4b..96e5861f1c 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
@@ -82,7 +82,10 @@ import org.slf4j.LoggerFactory;
  * {@link #advancePhase()}.
  *
  * <p>An {@link EqualityConvertPlan} with the current cycle's metadata is 
emitted via the {@link
- * #METADATA_STREAM} side output after the read commands.
+ * #METADATA_STREAM} side output after the read commands. The planner emits 
phase watermarks before
+ * that plan. A {@link EqualityConvertPlan#cycleStart} marker is emitted 
first, before any phase
+ * watermark. The marker tells the committer a cycle has started, and the 
committer holds watermarks
+ * until the commit.
  *
  * <p>Assumes a single equality-field set supplied via the builder; staging 
eq-deletes with a
  * different {@code equalityFieldIds} fail fast in {@link 
#retrieveStagingFiles}. Concurrent writes
@@ -264,6 +267,12 @@ public class EqualityConvertPlanner extends 
AbstractStreamOperator<ReadCommand>
     long triggerTs = element.getTimestamp();
     nextPhaseTs = Math.max(triggerTs, nextPhaseTs + 1);
 
+    // Mark cycle start before the first phase watermark. The committer holds 
back watermarks from
+    // here until the plan arrives; without the marker it cannot tell a phase 
watermark emitted
+    // before the plan from an idle watermark that must be forwarded, and 
forwarding the former
+    // would release the maintenance lock before the commit.
+    output.collect(METADATA_STREAM, new 
StreamRecord<>(EqualityConvertPlan.cycleStart(triggerTs)));
+
     Long currentMainSnapshotId = lastMainSnapshotId;
     try {
       table.refresh();
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 f3bdaa8fe1..e39d05b66f 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
@@ -19,13 +19,22 @@
 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.TableMaintenanceMetrics.SUCCEEDED_TASK_COUNTER;
 import static org.assertj.core.api.Assertions.assertThat;
 
 import java.io.File;
 import java.io.IOException;
+import java.io.Serializable;
 import java.nio.file.Path;
 import java.time.Duration;
+import java.util.Collections;
+import java.util.List;
+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.core.execution.JobClient;
+import org.apache.flink.streaming.api.datastream.DataStream;
 import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
 import org.apache.iceberg.DataFile;
 import org.apache.iceberg.DeleteFile;
@@ -42,12 +51,16 @@ import org.apache.iceberg.Table;
 import org.apache.iceberg.data.FileHelpers;
 import org.apache.iceberg.data.GenericAppenderHelper;
 import org.apache.iceberg.flink.SimpleDataUtil;
+import org.apache.iceberg.flink.maintenance.operator.ManualSource;
+import 
org.apache.iceberg.flink.maintenance.operator.MetricsReporterFactoryForTests;
 import org.apache.iceberg.flink.maintenance.operator.OperatorTestBase;
+import org.apache.iceberg.flink.maintenance.operator.TableChange;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
 import org.apache.iceberg.relocated.com.google.common.collect.Lists;
 import org.apache.iceberg.util.ContentFileUtil;
 import org.awaitility.Awaitility;
 import org.junit.jupiter.api.BeforeEach;
+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;
@@ -60,6 +73,7 @@ import org.junit.jupiter.params.provider.ValueSource;
  */
 class TestConvertEqualityDeletesE2E extends OperatorTestBase {
   private static final String STAGING_BRANCH = "staging";
+  private static final String PROBE_TASK_NAME = "SharedLockProbe";
 
   @TempDir private Path tempDir;
   private StreamExecutionEnvironment env;
@@ -136,6 +150,76 @@ class TestConvertEqualityDeletesE2E extends 
OperatorTestBase {
     }
   }
 
+  /**
+   * ConvertEqualityDeletes shares the Flink-maintained maintenance lock with 
another task. The lock
+   * is released only when the unioned watermark reaches the lock remover, so 
a watermark from the
+   * other task has to pass through an idle converter.
+   */
+  @Test
+  void releasesSharedLockAfterEachCycle() throws Exception {
+    Table table = createTable(3, FileFormat.PARQUET);
+    String tableName = table.name();
+
+    env.setParallelism(1);
+    ManualSource<TableChange> changes =
+        new ManualSource<>(env, TypeInformation.of(TableChange.class));
+
+    TableMaintenance.forChangeStream(changes.dataStream(), tableLoader())
+        .uidSuffix("ConvertEqualityDeletesSharedLock")
+        .rateLimit(Duration.ofMillis(50))
+        .lockCheckDelay(Duration.ofMillis(50))
+        .add(new CompletingTaskBuilder().scheduleOnCommitCount(1))
+        .add(
+            ConvertEqualityDeletes.builder()
+                .scheduleOnCommitCount(1)
+                .stagingBranch(SnapshotRef.MAIN_BRANCH)
+                .targetBranch(SnapshotRef.MAIN_BRANCH)
+                .equalityFieldColumns(ImmutableList.of("id"))
+                .parallelism(1))
+        .append();
+
+    List<String> probeSucceeded =
+        ImmutableList.of(
+            LOCK_REMOVER_OPERATOR_NAME,
+            tableName,
+            PROBE_TASK_NAME + " [0]",
+            "0",
+            SUCCEEDED_TASK_COUNTER);
+    List<String> converterSucceeded =
+        ImmutableList.of(
+            LOCK_REMOVER_OPERATOR_NAME,
+            tableName,
+            "ConvertEqualityDeletes [1]",
+            "1",
+            SUCCEEDED_TASK_COUNTER);
+
+    JobClient jobClient = null;
+    try {
+      jobClient = env.executeAsync();
+
+      // The probe runs first, while the converter has no plan. Its watermark 
must leave the
+      // converter or the shared lock stays held and the converter never 
starts.
+      changes.sendRecord(TableChange.builder().commitCount(1).build());
+      awaitSucceeded(probeSucceeded, 1L);
+      awaitSucceeded(converterSucceeded, 1L);
+
+      changes.sendRecord(TableChange.builder().commitCount(1).build());
+      awaitSucceeded(probeSucceeded, 2L);
+      awaitSucceeded(converterSucceeded, 2L);
+    } finally {
+      closeJobClient(jobClient);
+    }
+  }
+
+  private static void awaitSucceeded(List<String> counterKey, long expected) {
+    Awaitility.await()
+        .atMost(Duration.ofMinutes(3))
+        .pollInterval(Duration.ofMillis(200))
+        .untilAsserted(
+            () ->
+                
assertThat(MetricsReporterFactoryForTests.counter(counterKey)).isEqualTo(expected));
+  }
+
   private DataFile writeDataFile(Table table, Integer id, String data) throws 
IOException {
     return new GenericAppenderHelper(table, FileFormat.PARQUET, tempDir)
         .writeFile(Lists.newArrayList(SimpleDataUtil.createRecord(id, data)));
@@ -193,4 +277,36 @@ class TestConvertEqualityDeletesE2E extends 
OperatorTestBase {
 
     return count;
   }
+
+  private static class CompletingTaskBuilder extends 
MaintenanceTaskBuilder<CompletingTaskBuilder> {
+
+    @Override
+    String maintenanceTaskName() {
+      return PROBE_TASK_NAME;
+    }
+
+    @Override
+    DataStream<TaskResult> append(DataStream<Trigger> trigger) {
+      return setSlotSharingGroup(
+          trigger
+              .map(new CompletingTask())
+              .name(maintenanceTaskName())
+              .uid(uidSuffix() + "-shared-lock-probe")
+              .forceNonParallel());
+    }
+  }
+
+  private static class CompletingTask
+      implements MapFunction<Trigger, TaskResult>, 
ResultTypeQueryable<TaskResult>, Serializable {
+
+    @Override
+    public TaskResult map(Trigger trigger) {
+      return new TaskResult(trigger.taskId(), trigger.timestamp(), true, 
Collections.emptyList());
+    }
+
+    @Override
+    public TypeInformation<TaskResult> getProducedType() {
+      return TypeInformation.of(TaskResult.class);
+    }
+  }
 }
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 53e98a06b4..35aa9908bc 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
@@ -133,6 +133,67 @@ class TestEqualityConvertCommitter extends 
OperatorTestBase {
     }
   }
 
+  @Test
+  void holdsBackPhaseWatermarkBeforePlanArrives() throws Exception {
+    Table table = createTable(3, FileFormat.PARQUET);
+    insert(table, 1, "a");
+
+    DataFile stagingDataFile = getFirstDataFile(table);
+    long snapshotIdBefore = table.currentSnapshot().snapshotId();
+
+    try (TwoInputStreamOperatorTestHarness<DVWriteResult, EqualityConvertPlan, 
Trigger> harness =
+        createHarness()) {
+      harness.open();
+
+      long triggerTs = System.currentTimeMillis();
+      long doneTs = triggerTs + 10;
+
+      // The planner marks cycle start, then emits phase watermarks before the 
plan. The pre-plan
+      // phase watermark must not escape: forwarding it would let the 
LockRemover release the
+      // maintenance lock before this cycle commits.
+      harness.processElement2(new 
StreamRecord<>(EqualityConvertPlan.cycleStart(triggerTs)));
+      harness.processBothWatermarks(new Watermark(triggerTs + 1));
+      assertThat(watermarks(harness)).isEmpty();
+      assertThat(harness.extractOutputValues()).isEmpty();
+
+      // The plan arrives; the done-timestamp watermark then commits the cycle 
and forwards.
+      EqualityConvertPlan planResult =
+          new EqualityConvertPlan(
+              Lists.newArrayList(stagingDataFile),
+              Lists.newArrayList(),
+              Lists.newArrayList(),
+              42L,
+              snapshotIdBefore,
+              triggerTs,
+              doneTs);
+      harness.processElement2(new StreamRecord<>(planResult));
+      harness.processBothWatermarks(new Watermark(doneTs));
+
+      assertThat(harness.extractOutputValues()).hasSize(1);
+      assertThat(watermarks(harness)).containsExactly(new Watermark(doneTs));
+      table.refresh();
+      
assertThat(table.currentSnapshot().snapshotId()).isNotEqualTo(snapshotIdBefore);
+    }
+  }
+
+  @Test
+  void forwardsWatermarkWithoutActiveCycle() throws Exception {
+    Table table = createTable(3, FileFormat.PARQUET);
+    insert(table, 1, "a");
+
+    try (TwoInputStreamOperatorTestHarness<DVWriteResult, EqualityConvertPlan, 
Trigger> harness =
+        createHarness()) {
+      harness.open();
+
+      // No plan was received, so no conversion cycle is active. A watermark 
here originates
+      // from another task's trigger sharing the maintenance lock; dropping it 
would stall
+      // the unioned watermark and the lock would never be released.
+      long markTs = System.currentTimeMillis();
+      harness.processBothWatermarks(new Watermark(markTs));
+      assertThat(watermarks(harness)).containsExactly(new Watermark(markTs));
+    }
+  }
+
   @Test
   void skipsCommitForEmptyCycle() throws Exception {
     Table table = createTable(3, FileFormat.PARQUET);
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 56c6a2f98b..93614fe8ab 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
@@ -137,6 +137,37 @@ class TestEqualityConvertDVWriter extends OperatorTestBase 
{
     }
   }
 
+  @Test
+  void ignoresCycleStartMarker() throws Exception {
+    Table table = createTableWithDelete(3);
+    insert(table, 1, "a");
+
+    String dataFilePath = getFirstDataFilePath(table);
+
+    try (TwoInputStreamOperatorTestHarness<DVPosition, EqualityConvertPlan, 
DVWriteResult> harness =
+        createHarness()) {
+      harness.open();
+
+      long time = System.currentTimeMillis();
+      harness.processElement1(
+          new StreamRecord<>(new DVPosition(dataFilePath, 0, 0, 
EMPTY_PARTITION, 0L), time));
+
+      // The cycle-start marker is not a plan: it must not trigger a write.
+      harness.processElement2(new 
StreamRecord<>(EqualityConvertPlan.cycleStart(time), time));
+      harness.processBothWatermarks(new Watermark(time));
+      assertThat(harness.extractOutputValues()).isEmpty();
+
+      // The real plan that follows still drives the write. The watermark must 
advance past the
+      // previous one for the operator to process it.
+      harness.processElement2(new StreamRecord<>(emptyEqualityConvertPlan(), 
time));
+      harness.processBothWatermarks(new Watermark(time + 1));
+
+      List<DVWriteResult> output = harness.extractOutputValues();
+      assertThat(output).hasSize(1);
+      assertThat(output.get(0).hasError()).isFalse();
+    }
+  }
+
   @Test
   void writesDVFilesForMultipleDataFiles() throws Exception {
     Table table = createTableWithDelete(3);
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 ed243695bc..1482ce8726 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
@@ -84,7 +84,7 @@ class TestEqualityConvertPlanner extends OperatorTestBase {
       assertThat(countDataFileTasks(commands)).isEqualTo(2);
       assertThat(countEqDeleteTasks(commands)).isZero();
 
-      
assertThat(harness.getSideOutput(EqualityConvertPlanner.METADATA_STREAM)).hasSize(1);
+      
assertThat(harness.getSideOutput(EqualityConvertPlanner.METADATA_STREAM)).hasSize(2);
     }
   }
 
@@ -202,8 +202,9 @@ class TestEqualityConvertPlanner extends OperatorTestBase {
 
       List<StreamRecord<EqualityConvertPlan>> metadata =
           
Lists.newArrayList(harness.getSideOutput(EqualityConvertPlanner.METADATA_STREAM));
-      assertThat(metadata).hasSize(1);
-      assertThat(metadata.get(0).getValue().dataFiles()).isEmpty();
+      assertThat(metadata).hasSize(2);
+      
assertThat(metadata.get(0).getValue().doneTimestamp()).isEqualTo(Long.MAX_VALUE);
+      assertThat(metadata.get(1).getValue().dataFiles()).isEmpty();
     }
   }
 
@@ -280,8 +281,9 @@ class TestEqualityConvertPlanner extends OperatorTestBase {
 
       List<StreamRecord<EqualityConvertPlan>> metadata =
           
Lists.newArrayList(harness.getSideOutput(EqualityConvertPlanner.METADATA_STREAM));
-      assertThat(metadata).hasSize(1);
-      assertThat(metadata.get(0).getValue().dataFiles()).hasSize(1);
+      assertThat(metadata).hasSize(2);
+      
assertThat(metadata.get(0).getValue().doneTimestamp()).isEqualTo(Long.MAX_VALUE);
+      assertThat(metadata.get(1).getValue().dataFiles()).hasSize(1);
     }
   }
 
@@ -371,7 +373,7 @@ class TestEqualityConvertPlanner extends OperatorTestBase {
       // emitted because there's nothing on staging to convert.
       
assertThat(countDataFileTasks(harness.extractOutputValues())).isEqualTo(1);
       assertThat(countEqDeleteTasks(harness.extractOutputValues())).isZero();
-      
assertThat(harness.getSideOutput(EqualityConvertPlanner.METADATA_STREAM)).hasSize(1);
+      
assertThat(harness.getSideOutput(EqualityConvertPlanner.METADATA_STREAM)).hasSize(2);
       assertThat(planner(harness).skippedNoOpCycles()).isEqualTo(1);
       assertThat(planner(harness).reindexCount()).isZero();
     }
@@ -405,8 +407,9 @@ class TestEqualityConvertPlanner extends OperatorTestBase {
 
       List<StreamRecord<EqualityConvertPlan>> metadata =
           
Lists.newArrayList(harness.getSideOutput(EqualityConvertPlanner.METADATA_STREAM));
-      assertThat(metadata).hasSize(1);
-      EqualityConvertPlan result = metadata.get(0).getValue();
+      assertThat(metadata).hasSize(2);
+      
assertThat(metadata.get(0).getValue().doneTimestamp()).isEqualTo(Long.MAX_VALUE);
+      EqualityConvertPlan result = metadata.get(1).getValue();
       assertThat(result.dataFiles()).isEmpty();
       assertThat(result.stagingDVFiles()).hasSize(1);
       
assertThat(result.stagingDVFiles().get(0).location()).isEqualTo(stagingDV.location());

Reply via email to