This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 1851acf782 [flink] Support idle watermark handling in
coordinator-commit (#8714)
1851acf782 is described below
commit 1851acf782c4c44f19097c4d6ca7820a80e0fd70
Author: Biao Liu <[email protected]>
AuthorDate: Tue Jul 21 14:48:14 2026 +0800
[flink] Support idle watermark handling in coordinator-commit (#8714)
---
...dinatorCommittingRowDataStoreWriteOperator.java | 20 +-
.../sink/coordinator/CheckpointCommittables.java | 20 +-
.../CheckpointCommittablesSerializer.java | 12 +-
.../CommittingWriteOperatorCoordinator.java | 42 ++--
.../flink/sink/coordinator/SubtaskWatermark.java | 44 ++++
.../flink/sink/coordinator/WatermarkAligner.java | 126 ++++++++++
.../flink/sink/coordinator/WriterCommittables.java | 10 +
.../paimon/flink/CoordinatorCommitITCase.java | 254 +++++++++++++++++++++
...torCommittingRowDataStoreWriteOperatorTest.java | 90 ++++++++
.../CheckpointCommittablesSerializerTest.java | 87 +++++++
.../CommittingWriteOperatorCoordinatorTest.java | 43 +++-
.../sink/coordinator/WatermarkAlignerTest.java | 113 +++++++++
.../sink/coordinator/WriterCommittablesTest.java | 22 ++
13 files changed, 859 insertions(+), 24 deletions(-)
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperator.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperator.java
index 1ff8ab06dd..3bd617b5eb 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperator.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperator.java
@@ -40,6 +40,7 @@ import
org.apache.flink.streaming.api.operators.StreamOperatorParameters;
import org.apache.flink.streaming.api.operators.util.SimpleVersionedListState;
import org.apache.flink.streaming.api.watermark.Watermark;
import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.streaming.runtime.watermarkstatus.WatermarkStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -79,6 +80,15 @@ public class CoordinatorCommittingRowDataStoreWriteOperator
/** Latest watermark observed on the input; forwarded on subsequent
events. */
private transient long currentWatermark;
+ /**
+ * Latest {@code WatermarkStatus} observed on the input, mirroring what
Flink's upstream {@code
+ * StatusWatermarkValve} exposes. Frozen at barrier time alongside {@link
#currentWatermark} so
+ * the coordinator can reproduce valve-faithful idle handling from the
per-checkpoint entries.
+ * Not checkpointed: on restore we default to ACTIVE and let upstream
re-emit {@link
+ * WatermarkStatus#IDLE} if it still applies, matching Flink valve's
rebuilt initial state.
+ */
+ private transient boolean currentIdle;
+
private transient CheckpointCommittablesSerializer stateSerializer;
private transient TypeSerializer<CheckpointCommittables> eventSerializer;
@@ -116,6 +126,7 @@ public class CoordinatorCommittingRowDataStoreWriteOperator
stateSerializer);
pendingCommittables = new TreeMap<>();
currentWatermark = Long.MIN_VALUE;
+ currentIdle = false;
if (context.isRestored()) {
Preconditions.checkState(context.getRestoredCheckpointId().isPresent());
@@ -159,7 +170,8 @@ public class CoordinatorCommittingRowDataStoreWriteOperator
protected void emitCommittables(boolean waitCompaction, long checkpointId)
throws IOException {
List<Committable> committables = prepareCommit(waitCompaction,
checkpointId);
CheckpointCommittables entry =
- new CheckpointCommittables(checkpointId, committables,
currentWatermark);
+ new CheckpointCommittables(
+ checkpointId, committables, currentWatermark,
currentIdle);
// Emit an event per (subtask, checkpoint) regardless of whether
committables is empty.
operatorEventGateway.sendEventToCoordinator(
CommittableEvent.create(checkpointId, entry, eventSerializer));
@@ -182,6 +194,12 @@ public class CoordinatorCommittingRowDataStoreWriteOperator
}
}
+ @Override
+ public void processWatermarkStatus(WatermarkStatus watermarkStatus) throws
Exception {
+ super.processWatermarkStatus(watermarkStatus);
+ currentIdle = watermarkStatus.isIdle();
+ }
+
@VisibleForTesting
NavigableMap<Long, CheckpointCommittables> getPendingCommittables() {
return pendingCommittables;
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittables.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittables.java
index 3eafc33c69..3e08c1f77f 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittables.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittables.java
@@ -32,12 +32,22 @@ public class CheckpointCommittables {
private final long checkpointId;
private final List<Committable> committables;
private final long watermark;
+ // Idle bit is frozen at barrier time together with watermark; mirrors
what Flink's
+ // StatusWatermarkValve would have observed on the writer's input at the
moment of the barrier.
+ private final boolean idle;
public CheckpointCommittables(
- long checkpointId, List<Committable> committables, long watermark)
{
+ long checkpointId, List<Committable> committables, long watermark,
boolean idle) {
this.checkpointId = checkpointId;
this.committables = committables;
this.watermark = watermark;
+ this.idle = idle;
+ }
+
+ // Convenience for callers that only need the pre-idle-aware shape (ACTIVE
writer).
+ public CheckpointCommittables(
+ long checkpointId, List<Committable> committables, long watermark)
{
+ this(checkpointId, committables, watermark, false);
}
public long checkpointId() {
@@ -52,6 +62,10 @@ public class CheckpointCommittables {
return watermark;
}
+ public boolean idle() {
+ return idle;
+ }
+
public int size() {
return committables.size();
}
@@ -63,7 +77,7 @@ public class CheckpointCommittables {
@Override
public String toString() {
return String.format(
- "CheckpointCommittables{checkpointId=%d, watermark=%d,
committables=%s}",
- checkpointId, watermark, committables);
+ "CheckpointCommittables{checkpointId=%d, watermark=%d,
idle=%s, committables=%s}",
+ checkpointId, watermark, idle, committables);
}
}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializer.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializer.java
index 9855964dff..b557559996 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializer.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializer.java
@@ -41,7 +41,9 @@ public class CheckpointCommittablesSerializer
@Override
public int getVersion() {
- return 1;
+ // v1: checkpointId + watermark + committables
+ // v2: v1 + idle bit (appended before the committable list to keep the
ordering explicit)
+ return 2;
}
@Override
@@ -49,6 +51,7 @@ public class CheckpointCommittablesSerializer
DataOutputSerializer out = new DataOutputSerializer(256);
out.writeLong(value.checkpointId());
out.writeLong(value.watermark());
+ out.writeBoolean(value.idle());
// Nested serializer version comes before the list so the reader can
pick the right decoder
// before touching any list bytes — mirrors
ManifestCommittableSerializer's layout.
out.writeInt(committableSerializer.getVersion());
@@ -64,12 +67,15 @@ public class CheckpointCommittablesSerializer
@Override
public CheckpointCommittables deserialize(int version, byte[] serialized)
throws IOException {
- if (version != getVersion()) {
+ if (version != 1 && version != 2) {
throw new IOException("Unknown version " + version);
}
DataInputDeserializer in = new DataInputDeserializer(serialized);
long checkpointId = in.readLong();
long watermark = in.readLong();
+ // v1 payloads pre-date idle tracking; default to ACTIVE (idle=false),
which reproduces
+ // the pre-idle-aware behaviour: every subtask contributes to the min
unconditionally.
+ boolean idle = version >= 2 && in.readBoolean();
int committableVersion = in.readInt();
int count = in.readInt();
List<Committable> committables = new ArrayList<>(count);
@@ -79,6 +85,6 @@ public class CheckpointCommittablesSerializer
in.readFully(bytes);
committables.add(committableSerializer.deserialize(committableVersion, bytes));
}
- return new CheckpointCommittables(checkpointId, committables,
watermark);
+ return new CheckpointCommittables(checkpointId, committables,
watermark, idle);
}
}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinator.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinator.java
index f57e4ec149..20f3b65b88 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinator.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinator.java
@@ -83,6 +83,9 @@ public class CommittingWriteOperatorCoordinator implements
OperatorCoordinator {
private final TypeSerializer<CheckpointCommittables>
committablesSerializer;
private final CoordinatorStateSerializer stateSerializer;
private final ExecutorService commitExecutor;
+ // Rebuilt per coordinator instance; state is purely in-memory, matching
Flink's
+ // StatusWatermarkValve which is also reconstructed per task instance
without checkpointing.
+ private final WatermarkAligner watermarkAligner;
// Populated by resetToCheckpoint and consumed by start. Plain fields are
sufficient: both
// callbacks run on the same scheduler thread in order.
@@ -116,6 +119,7 @@ public class CommittingWriteOperatorCoordinator implements
OperatorCoordinator {
this.commitExecutor =
Executors.newSingleThreadExecutor(
new
CoordinatorExecutorThreadFactory("WriteCommitCoordinator", context));
+ this.watermarkAligner = new WatermarkAligner(parallelism);
this.state = State.CREATED;
}
@@ -215,7 +219,8 @@ public class CommittingWriteOperatorCoordinator implements
OperatorCoordinator {
throw new IllegalStateException("Not all committables
reported by writer");
}
Map<Long, Long> watermarkPerCheckpoint =
- alignWatermarkPerCheckpoint(checkpointId,
subtaskCommittables);
+ alignWatermarkPerCheckpoint(
+ checkpointId, subtaskCommittables,
watermarkAligner);
commitUpToCheckpoint(
checkpointId,
pollManifestCommittablesForCheckpoint(
@@ -352,7 +357,8 @@ public class CommittingWriteOperatorCoordinator implements
OperatorCoordinator {
if (failoverAfterRecovery) {
// recommit the restored committables and trigger a failover to
reinitialize all writers
Map<Long, Long> watermarkPerCheckpoint =
- alignWatermarkPerCheckpoint(checkpointId,
subtaskCommittables);
+ alignWatermarkPerCheckpoint(
+ checkpointId, subtaskCommittables,
watermarkAligner);
commitUpToCheckpoint(
checkpointId,
pollManifestCommittablesForCheckpoint(
@@ -416,16 +422,15 @@ public class CommittingWriteOperatorCoordinator
implements OperatorCoordinator {
}
/**
- * Reduce the per-subtask watermark of each checkpoint (up to {@code
checkpointId}, inclusive)
- * into a single watermark to attach to the committed snapshot. Every
subtask must have an entry
- * for {@code checkpointId} by contract (writers emit one event per
barrier, even empty), so
- * this method observes each subtask through {@link
WriterCommittables#watermarkAt}, which
- * returns {@link Long#MIN_VALUE} for missing entries — matching {@code
CommitterOperator}'s
- * initial-watermark semantics and giving idle handling a single hook to
grow into later.
+ * Aggregate each pending checkpoint's per-subtask (watermark, idle) pairs
into a single
+ * watermark by delegating to {@link WatermarkAligner}. Returns a map from
checkpoint id to the
+ * aligned watermark, covering every checkpoint up to {@code checkpointId}
inclusive.
*/
@VisibleForTesting
static Map<Long, Long> alignWatermarkPerCheckpoint(
- long checkpointId, WriterCommittables[] subtaskCommittables) {
+ long checkpointId, WriterCommittables[] subtaskCommittables,
WatermarkAligner aligner) {
+ // TreeSet keeps checkpoint ids in ascending order, matching the
aligner's contract that
+ // successive align() calls advance monotonically.
Set<Long> checkpoints = new TreeSet<>();
for (WriterCommittables committables : subtaskCommittables) {
checkpoints.addAll(
@@ -433,15 +438,24 @@ public class CommittingWriteOperatorCoordinator
implements OperatorCoordinator {
}
Map<Long, Long> watermarkPerCheckpoint = new HashMap<>();
for (long cp : checkpoints) {
- long min = Long.MAX_VALUE;
- for (WriterCommittables committables : subtaskCommittables) {
- min = Math.min(min, committables.watermarkAt(cp));
- }
- watermarkPerCheckpoint.put(cp, min);
+ watermarkPerCheckpoint.put(
+ cp, aligner.align(subtaskWatermarksAt(cp,
subtaskCommittables)));
}
return watermarkPerCheckpoint;
}
+ private static SubtaskWatermark[] subtaskWatermarksAt(
+ long checkpointId, WriterCommittables[] subtaskCommittables) {
+ SubtaskWatermark[] subtaskWatermarks = new
SubtaskWatermark[subtaskCommittables.length];
+ for (int i = 0; i < subtaskCommittables.length; i++) {
+ subtaskWatermarks[i] =
+ new SubtaskWatermark(
+ subtaskCommittables[i].watermarkAt(checkpointId),
+ subtaskCommittables[i].isIdleAt(checkpointId));
+ }
+ return subtaskWatermarks;
+ }
+
private void commitUpToCheckpoint(
long checkpointId,
Map<Long, ManifestCommittable> toCommit,
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/SubtaskWatermark.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/SubtaskWatermark.java
new file mode 100644
index 0000000000..ef83aacfb2
--- /dev/null
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/SubtaskWatermark.java
@@ -0,0 +1,44 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.flink.sink.coordinator;
+
+/**
+ * Point-in-time watermark reading from a single subtask, paired with whether
that subtask was idle
+ * at the moment the reading was taken. Consumed by {@link WatermarkAligner}
to reproduce {@code
+ * StatusWatermarkValve} semantics over inputs that arrive via the {@code
OperatorEvent} channel
+ * rather than the operator's own edge.
+ */
+public final class SubtaskWatermark {
+
+ private final long watermark;
+ private final boolean idle;
+
+ public SubtaskWatermark(long watermark, boolean idle) {
+ this.watermark = watermark;
+ this.idle = idle;
+ }
+
+ public long watermark() {
+ return watermark;
+ }
+
+ public boolean idle() {
+ return idle;
+ }
+}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/WatermarkAligner.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/WatermarkAligner.java
new file mode 100644
index 0000000000..277b7503c2
--- /dev/null
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/WatermarkAligner.java
@@ -0,0 +1,126 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.flink.sink.coordinator;
+
+/**
+ * Coordinator-side counterpart of Flink's {@code StatusWatermarkValve}.
Aggregates a per-subtask
+ * ({@link SubtaskWatermark} array indexed by subtask id) into a single
monotonically non-decreasing
+ * watermark.
+ *
+ * <p>The algorithm mirrors {@code StatusWatermarkValve} channel-by-channel:
+ *
+ * <ul>
+ * <li>A subtask that reports {@code idle=true} becomes unaligned and is
excluded from the
+ * aligned-set min.
+ * <li>A subtask that reports {@code idle=false} rejoins the aligned set
once its watermark has
+ * caught up to the last emitted watermark; while lagging it stays
unaligned so it does not
+ * drag the global watermark backwards.
+ * <li>If every subtask is unaligned (all idle, or all lagging), the aligner
emits a one-shot
+ * "flush max" — the maximum over every subtask's last known watermark —
matching what {@code
+ * StatusWatermarkValve} does when the last active channel transitions
to IDLE.
+ * </ul>
+ *
+ * <p>State is purely in-memory: the aligner mirrors {@code
StatusWatermarkValve}'s "no checkpoint,
+ * rebuild on restart" contract. A fresh instance starts every subtask ACTIVE
with {@code
+ * Long.MIN_VALUE}, so upstream must re-send {@code WatermarkStatus.IDLE} for
it to take effect
+ * again — same as Flink's built-in valve on task restart.
+ */
+public class WatermarkAligner {
+
+ private final boolean[] aligned;
+
+ private long lastEmittedWatermark;
+ private boolean idleStatus;
+
+ public WatermarkAligner(int parallelism) {
+ this.aligned = new boolean[parallelism];
+ for (int i = 0; i < parallelism; i++) {
+ aligned[i] = true;
+ }
+ this.lastEmittedWatermark = Long.MIN_VALUE;
+ this.idleStatus = false;
+ }
+
+ /**
+ * Aggregate the given per-subtask readings into a single watermark.
Updates the aligner's
+ * internal per-subtask alignment as a side effect, so successive calls
see valve-faithful
+ * catch-up semantics.
+ *
+ * <p>Contract: successive calls must correspond to strictly increasing
checkpoint ids. Calling
+ * out of order would apply a later checkpoint's alignment side effects to
an earlier one,
+ * silently corrupting the emitted watermark sequence.
+ *
+ * @return the aligned watermark for this call, guaranteed to be
monotonically non-decreasing
+ * across successive calls.
+ */
+ public long align(SubtaskWatermark[] subtaskWatermarks) {
+ int parallelism = subtaskWatermarks.length;
+ if (parallelism != aligned.length) {
+ throw new IllegalStateException(
+ "Aligner parallelism "
+ + aligned.length
+ + " does not match input "
+ + parallelism);
+ }
+
+ long alignedMin = Long.MAX_VALUE;
+ boolean anyAligned = false;
+ for (int i = 0; i < parallelism; i++) {
+ long watermark = subtaskWatermarks[i].watermark();
+ boolean idle = subtaskWatermarks[i].idle();
+ if (idle) {
+ aligned[i] = false;
+ continue;
+ }
+ if (!aligned[i] && watermark >= lastEmittedWatermark) {
+ aligned[i] = true;
+ }
+ if (aligned[i]) {
+ anyAligned = true;
+ if (watermark < alignedMin) {
+ alignedMin = watermark;
+ }
+ }
+ }
+
+ if (anyAligned) {
+ if (alignedMin > lastEmittedWatermark) {
+ lastEmittedWatermark = alignedMin;
+ }
+ idleStatus = false;
+ } else if (!idleStatus) {
+ // All-unaligned transition: flush the max across every subtask's
last known watermark
+ // (equivalent to
StatusWatermarkValve.findAndOutputMaxWatermarkAcrossAllSubpartitions),
+ // then latch to IDLE so subsequent all-unaligned checkpoints do
not re-flush.
+ long flushMax = Long.MIN_VALUE;
+ for (int i = 0; i < parallelism; i++) {
+ long watermark = subtaskWatermarks[i].watermark();
+ if (watermark > flushMax) {
+ flushMax = watermark;
+ }
+ }
+ if (flushMax > lastEmittedWatermark) {
+ lastEmittedWatermark = flushMax;
+ }
+ idleStatus = true;
+ }
+
+ return lastEmittedWatermark;
+ }
+}
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/WriterCommittables.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/WriterCommittables.java
index 243969069b..1562fbf4ee 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/WriterCommittables.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/sink/coordinator/WriterCommittables.java
@@ -135,6 +135,16 @@ public class WriterCommittables {
return entry == null ? Long.MIN_VALUE : entry.watermark();
}
+ /**
+ * Returns whether this subtask was idle at {@code checkpointId}. Falls
back to {@code false}
+ * (ACTIVE) when the subtask has no entry — equivalent to "channel exists
but has never reported
+ * a watermark yet", which Flink's {@code StatusWatermarkValve} also
treats as ACTIVE.
+ */
+ public boolean isIdleAt(long checkpointId) {
+ CheckpointCommittables entry =
committablesPerCheckpoint.get(checkpointId);
+ return entry != null && entry.idle();
+ }
+
@Override
public String toString() {
return String.format(
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/CoordinatorCommitITCase.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/CoordinatorCommitITCase.java
index 0ddbfbd18c..eaa541e099 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/CoordinatorCommitITCase.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/CoordinatorCommitITCase.java
@@ -18,26 +18,42 @@
package org.apache.paimon.flink;
+import org.apache.paimon.Snapshot;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.flink.sink.FlinkSinkBuilder;
+import org.apache.paimon.flink.source.AbstractNonCoordinatedSource;
+import org.apache.paimon.flink.source.AbstractNonCoordinatedSourceReader;
+import org.apache.paimon.flink.source.SimpleSourceSplit;
import org.apache.paimon.reader.RecordReader;
import org.apache.paimon.reader.RecordReaderIterator;
import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.utils.CloseableIterator;
import org.apache.flink.api.common.JobID;
+import org.apache.flink.api.common.eventtime.Watermark;
+import org.apache.flink.api.connector.source.Boundedness;
+import org.apache.flink.api.connector.source.ReaderOutput;
+import org.apache.flink.api.connector.source.SourceReader;
+import org.apache.flink.api.connector.source.SourceReaderContext;
import org.apache.flink.client.program.ClusterClient;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.core.execution.JobClient;
+import org.apache.flink.core.io.InputStatus;
import org.apache.flink.metrics.Metric;
import org.apache.flink.metrics.MetricGroup;
import org.apache.flink.metrics.groups.OperatorMetricGroup;
import org.apache.flink.runtime.client.JobStatusMessage;
import org.apache.flink.runtime.testutils.InMemoryReporter;
import org.apache.flink.runtime.testutils.MiniClusterResourceConfiguration;
+import org.apache.flink.streaming.api.datastream.DataStreamSource;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.EnvironmentSettings;
import org.apache.flink.table.api.TableEnvironment;
import org.apache.flink.table.api.TableResult;
+import org.apache.flink.table.data.GenericRowData;
+import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
@@ -54,6 +70,7 @@ import static org.assertj.core.api.Assertions.assertThat;
public class CoordinatorCommitITCase {
private static final int DEFAULT_PARALLELISM = 2;
+ private static final int SCRIPTED_PARALLELISM = 2;
private static final long WAIT_TIMEOUT_MILLIS = 60_000L;
private static final InMemoryReporter reporter = InMemoryReporter.create();
@@ -110,6 +127,114 @@ public class CoordinatorCommitITCase {
assertThat(readRowCount(runningJob.table)).isGreaterThan(0L);
}
+ /**
+ * Idle watermark parity: the snapshot watermark observed with
coordinator-commit enabled must
+ * match the one produced by the classic {@code CommitterOperator} path
under the same input
+ * script. See {@link IdleWatermarkScriptedSource} for the three scenarios
covered — steady
+ * multi-active min, one subtask idle, and all subtasks idle.
+ */
+ @Timeout(value = 240, unit = TimeUnit.SECONDS)
+ @Test
+ public void testIdleWatermarkParityAcrossCommitPaths() throws Exception {
+ for (IdleWatermarkScriptedSource.Scenario scenario :
+ IdleWatermarkScriptedSource.Scenario.values()) {
+ long coordinatorWatermark = runIdleWatermarkScenario(scenario,
true);
+ long committerWatermark = runIdleWatermarkScenario(scenario,
false);
+ assertThat(coordinatorWatermark)
+ .describedAs(
+ "coordinator vs committer snapshot watermark
mismatch for scenario "
+ + scenario)
+ .isEqualTo(committerWatermark);
+ assertThat(coordinatorWatermark)
+ .describedAs("scenario " + scenario + " expected
watermark")
+ .isEqualTo(scenario.expectedWatermark);
+ }
+ }
+
+ private long runIdleWatermarkScenario(
+ IdleWatermarkScriptedSource.Scenario scenario, boolean
coordinatorCommitEnabled)
+ throws Exception {
+ String tableName =
+ "T_IDLE_"
+ + scenario.name()
+ + "_"
+ + (coordinatorCommitEnabled ? "COORD" : "CLASSIC");
+ TableEnvironment tEnv =
+ TableEnvironment.create(
+
EnvironmentSettings.newInstance().inStreamingMode().build());
+
tEnv.getConfig().getConfiguration().setString("execution.checkpointing.interval",
"200 ms");
+ tEnv.executeSql(
+ "CREATE CATALOG idlecat WITH ( 'type' = 'paimon', 'warehouse'
= '"
+ + tempPath
+ + "/"
+ + scenario.name()
+ + '_'
+ + coordinatorCommitEnabled
+ + "' )");
+ tEnv.executeSql("USE CATALOG idlecat");
+ // write-only=true is kept identical across both paths so the only
variable under test is
+ // sink.coordinator-commit.enabled; otherwise compaction snapshots
would perturb the
+ // "latest snapshot watermark" observations differently between paths.
+ String coordinatorOption =
+ coordinatorCommitEnabled ? ",
'sink.coordinator-commit.enabled' = 'true'" : "";
+ tEnv.executeSql(
+ "CREATE TABLE "
+ + tableName
+ + " (id INT, data STRING) WITH ("
+ + "'bucket' = '-1', 'write-only' = 'true'"
+ + coordinatorOption
+ + ")");
+
+ FileStoreTable table =
+ (FileStoreTable)
+ ((FlinkCatalog) tEnv.getCatalog("idlecat").get())
+ .catalog()
+ .getTable(Identifier.create("default",
tableName));
+
+ StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
+ env.setParallelism(SCRIPTED_PARALLELISM);
+ env.enableCheckpointing(200);
+
+ DataStreamSource<RowData> source =
+ env.fromSource(
+ new IdleWatermarkScriptedSource(scenario),
+
org.apache.flink.api.common.eventtime.WatermarkStrategy
+ .noWatermarks(),
+ "idle-watermark-source")
+ .setParallelism(SCRIPTED_PARALLELISM);
+
+ new FlinkSinkBuilder(table).forRowData(source).build();
+
+ JobClient client = env.executeAsync("idle-watermark-" + tableName);
+ JobID jobId = client.getJobID();
+ try {
+ // The option only expresses intent; a failed precondition would
silently fall back to
+ // the classic path. Confirm the intended path is actually running
by waiting for its
+ // own positive metric signal before trusting any watermark
observation.
+ if (coordinatorCommitEnabled) {
+ waitUntilCoordinatorCommitMetricsRegistered(jobId);
+ } else {
+ waitUntilGlobalCommitterMetricGroupsRegistered(jobId);
+ }
+
+ long deadline = System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS;
+ long observedWatermark = Long.MIN_VALUE;
+ while (System.currentTimeMillis() < deadline) {
+ Snapshot snapshot = table.snapshotManager().latestSnapshot();
+ if (snapshot != null && snapshot.watermark() != null) {
+ observedWatermark = snapshot.watermark();
+ if (observedWatermark >= scenario.expectedWatermark) {
+ break;
+ }
+ }
+ Thread.sleep(200);
+ }
+ return observedWatermark;
+ } finally {
+ client.cancel().get(30, TimeUnit.SECONDS);
+ }
+ }
+
private RunningJob startStreamingInsert(boolean coordinatorCommitEnabled)
throws Exception {
String tableName = coordinatorCommitEnabled ? "T_COORDINATOR_COMMIT" :
"T_DEFAULT_COMMIT";
TableEnvironment tEnv =
@@ -303,4 +428,133 @@ public class CoordinatorCommitITCase {
client.cancel().get(30, TimeUnit.SECONDS);
}
}
+
+ /**
+ * Scripted source that emits controlled ({@link Watermark}, {@link
+ * org.apache.flink.api.connector.source.SourceOutput#markIdle()})
sequences on a per-subtask
+ * basis, so the same input drives both commit paths deterministically
without depending on
+ * datagen timing. Subtask-0 and subtask-1 follow the {@code Scenario}'s
script; each step emits
+ * a watermark or marks the split idle, sleeps briefly to let checkpoints
run, then the source
+ * stays available forever so the job is only stopped by the outer cancel.
+ */
+ private static class IdleWatermarkScriptedSource extends
AbstractNonCoordinatedSource<RowData> {
+
+ private static final long serialVersionUID = 1L;
+
+ enum Scenario {
+ /** Both subtasks stay active; snapshot watermark equals the
smaller of the two. */
+ ALL_ACTIVE(300L),
+ /**
+ * Subtask-0 emits a small watermark then goes idle; subtask-1
keeps emitting bigger
+ * watermarks. The idle subtask must not hold the snapshot back.
+ */
+ PARTIAL_IDLE(700L),
+ /**
+ * Both subtasks emit a watermark and go idle. Once every input is
idle, Flink's valve
+ * flushes {@code max} over all channels — so the snapshot
watermark advances to the
+ * larger of the two, then stays put (not regresses back to {@code
Long.MIN_VALUE}).
+ */
+ ALL_IDLE(600L);
+
+ final long expectedWatermark;
+
+ Scenario(long expectedWatermark) {
+ this.expectedWatermark = expectedWatermark;
+ }
+ }
+
+ private final Scenario scenario;
+
+ IdleWatermarkScriptedSource(Scenario scenario) {
+ this.scenario = scenario;
+ }
+
+ @Override
+ public Boundedness getBoundedness() {
+ return Boundedness.CONTINUOUS_UNBOUNDED;
+ }
+
+ @Override
+ public SourceReader<RowData, SimpleSourceSplit> createReader(
+ SourceReaderContext sourceReaderContext) {
+ return new Reader(scenario,
sourceReaderContext.getIndexOfSubtask());
+ }
+
+ private static class Reader extends
AbstractNonCoordinatedSourceReader<RowData> {
+
+ private final Scenario scenario;
+ private final int subtaskIndex;
+
+ private int step;
+
+ Reader(Scenario scenario, int subtaskIndex) {
+ this.scenario = scenario;
+ this.subtaskIndex = subtaskIndex;
+ }
+
+ @Override
+ public InputStatus pollNext(ReaderOutput<RowData> output) throws
InterruptedException {
+ if (step == 0) {
+ // Emit one record so the writer has something to commit
and thus produces a
+ // snapshot carrying the watermark for us to observe.
+ output.collect(
+ GenericRowData.of(
+ subtaskIndex, StringData.fromString("s" +
subtaskIndex)));
+ }
+ switch (scenario) {
+ case ALL_ACTIVE:
+ return driveAllActive(output);
+ case PARTIAL_IDLE:
+ return drivePartialIdle(output);
+ case ALL_IDLE:
+ return driveAllIdle(output);
+ default:
+ throw new IllegalStateException("Unknown scenario " +
scenario);
+ }
+ }
+
+ private InputStatus driveAllActive(ReaderOutput<RowData> output)
+ throws InterruptedException {
+ // Both subtasks keep emitting increasing watermarks. Min
across subtasks = 300.
+ long watermark = subtaskIndex == 0 ? 300L : 500L;
+ output.emitWatermark(new Watermark(watermark));
+ Thread.sleep(200);
+ step++;
+ return InputStatus.MORE_AVAILABLE;
+ }
+
+ private InputStatus drivePartialIdle(ReaderOutput<RowData> output)
+ throws InterruptedException {
+ if (subtaskIndex == 0) {
+ if (step == 0) {
+ output.emitWatermark(new Watermark(100L));
+ Thread.sleep(200);
+ output.markIdle();
+ }
+ Thread.sleep(200);
+ step++;
+ return InputStatus.MORE_AVAILABLE;
+ }
+ // subtask-1 keeps advancing; peers at 700 by the second step.
+ long watermark = 500L + Math.min(step, 1) * 200L;
+ output.emitWatermark(new Watermark(watermark));
+ Thread.sleep(200);
+ step++;
+ return InputStatus.MORE_AVAILABLE;
+ }
+
+ private InputStatus driveAllIdle(ReaderOutput<RowData> output)
+ throws InterruptedException {
+ if (step == 0) {
+ long watermark = subtaskIndex == 0 ? 400L : 600L;
+ output.emitWatermark(new Watermark(watermark));
+ Thread.sleep(200);
+ output.markIdle();
+ }
+ Thread.sleep(200);
+ step++;
+ return InputStatus.MORE_AVAILABLE;
+ }
+ }
+ }
}
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperatorTest.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperatorTest.java
index 296eaa3823..bae18ea2c2 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperatorTest.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/CoordinatorCommittingRowDataStoreWriteOperatorTest.java
@@ -48,6 +48,7 @@ import
org.apache.flink.streaming.api.operators.StreamOperator;
import org.apache.flink.streaming.api.operators.StreamOperatorParameters;
import org.apache.flink.streaming.api.watermark.Watermark;
import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.streaming.runtime.watermarkstatus.WatermarkStatus;
import org.apache.flink.streaming.util.OneInputStreamOperatorTestHarness;
import org.apache.flink.util.FlinkRuntimeException;
import org.junit.jupiter.api.Test;
@@ -353,6 +354,95 @@ public class
CoordinatorCommittingRowDataStoreWriteOperatorTest extends Committe
secondHarness.close();
}
+ @Test
+ @Timeout(30)
+ public void testWatermarkStatusFrozenAtBarrierAcrossCheckpoints() throws
Exception {
+ FileStoreTable table = createUnawareBucketTable();
+ String commitUser = UUID.randomUUID().toString();
+ List<OperatorEvent> events = new ArrayList<>();
+
+ OneInputStreamOperatorTestHarness<InternalRow, Committable> harness =
+ createHarness(table, commitUser, events::add);
+ TypeSerializer<Committable> committableSerializer =
+ new CommittableTypeInfo().createSerializer(new
ExecutionConfig());
+ harness.setup(committableSerializer);
+ harness.open();
+
+ // cp1: writer is IDLE at barrier time.
+ harness.processWatermark(new Watermark(100L));
+ harness.processWatermarkStatus(WatermarkStatus.IDLE);
+ harness.prepareSnapshotPreBarrier(1);
+ harness.snapshot(1, 10);
+
+ // cp2: back to ACTIVE with a new watermark. Idle status must not
linger on cp2 just
+ // because cp1 was idle.
+ harness.processWatermarkStatus(WatermarkStatus.ACTIVE);
+ harness.processWatermark(new Watermark(500L));
+ harness.prepareSnapshotPreBarrier(2);
+ harness.snapshot(2, 20);
+
+ assertThat(events).hasSize(2);
+ CheckpointCommittables cp1 =
+ ((CommittableEvent)
events.get(0)).deserialize(COMMITTABLES_SERIALIZER);
+ assertThat(cp1.checkpointId()).isEqualTo(1L);
+ assertThat(cp1.watermark()).isEqualTo(100L);
+ assertThat(cp1.idle()).isTrue();
+
+ CheckpointCommittables cp2 =
+ ((CommittableEvent)
events.get(1)).deserialize(COMMITTABLES_SERIALIZER);
+ assertThat(cp2.checkpointId()).isEqualTo(2L);
+ assertThat(cp2.watermark()).isEqualTo(500L);
+ assertThat(cp2.idle()).isFalse();
+
+ harness.close();
+ }
+
+ @Test
+ @Timeout(30)
+ public void testIdleFlagResetsToActiveOnRestore() throws Exception {
+ FileStoreTable table = createUnawareBucketTable();
+ String commitUser = UUID.randomUUID().toString();
+ TypeSerializer<Committable> committableSerializer =
+ new CommittableTypeInfo().createSerializer(new
ExecutionConfig());
+
+ // session 1: end the session while IDLE so the restore path is
exercised on a snapshot
+ // taken from an idle writer.
+ List<OperatorEvent> firstEvents = new ArrayList<>();
+ OneInputStreamOperatorTestHarness<InternalRow, Committable>
firstHarness =
+ createHarness(table, commitUser, firstEvents::add);
+ firstHarness.setup(committableSerializer);
+ firstHarness.open();
+
+ firstHarness.processWatermark(new Watermark(100L));
+ firstHarness.processWatermarkStatus(WatermarkStatus.IDLE);
+ firstHarness.prepareSnapshotPreBarrier(1);
+ OperatorSubtaskState snapshot = firstHarness.snapshot(1, 10);
+ firstHarness.close();
+
+ // session 2: restore. Flink runtime does not replay the last
WatermarkStatus, and the
+ // aligner treats channels as ACTIVE + Long.MIN_VALUE on rebuild, so
the writer must also
+ // default to ACTIVE. The next barrier — before any WatermarkStatus
event — must emit
+ // idle=false, matching Flink's own valve-rebuild contract.
+ List<OperatorEvent> restoredEvents = new ArrayList<>();
+ OneInputStreamOperatorTestHarness<InternalRow, Committable>
secondHarness =
+ createHarness(table, commitUser, restoredEvents::add);
+ secondHarness.setup(committableSerializer);
+ restoreWithCheckpointId(secondHarness, snapshot, 1L);
+ secondHarness.open();
+
+ secondHarness.prepareSnapshotPreBarrier(2);
+ secondHarness.snapshot(2, 20);
+
+ // First event is the restore replay; the second is the freshly frozen
cp2.
+ assertThat(restoredEvents).hasSize(2);
+ CommittableEvent cp2Event = (CommittableEvent) restoredEvents.get(1);
+ CheckpointCommittables cp2 =
cp2Event.deserialize(COMMITTABLES_SERIALIZER);
+ assertThat(cp2.checkpointId()).isEqualTo(2L);
+ assertThat(cp2.idle()).isFalse();
+
+ secondHarness.close();
+ }
+
private void assertCommittableEventCheckpoint(OperatorEvent event, long
expectedCheckpointId) {
CommittableEvent committableEvent = (CommittableEvent) event;
assertThat(committableEvent.getCheckpointId()).isEqualTo(expectedCheckpointId);
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializerTest.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializerTest.java
new file mode 100644
index 0000000000..09b894d03d
--- /dev/null
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CheckpointCommittablesSerializerTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.flink.sink.coordinator;
+
+import org.apache.paimon.flink.sink.CommittableSerializer;
+import org.apache.paimon.table.sink.CommitMessageSerializer;
+
+import org.apache.flink.core.memory.DataOutputSerializer;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Unit tests for {@link CheckpointCommittablesSerializer}. */
+public class CheckpointCommittablesSerializerTest {
+
+ private final CheckpointCommittablesSerializer serializer =
+ new CheckpointCommittablesSerializer(
+ new CommittableSerializer(new CommitMessageSerializer()));
+
+ @Test
+ public void testCurrentVersionIsV2() {
+ assertThat(serializer.getVersion()).isEqualTo(2);
+ }
+
+ @Test
+ public void testRoundTripPreservesIdleFlag() throws IOException {
+ for (boolean idle : new boolean[] {true, false}) {
+ CheckpointCommittables original =
+ new CheckpointCommittables(
+ 42L, Collections.emptyList(), /* watermark */
4242L, idle);
+ CheckpointCommittables decoded =
+ serializer.deserialize(serializer.getVersion(),
serializer.serialize(original));
+ assertThat(decoded.checkpointId()).isEqualTo(42L);
+ assertThat(decoded.watermark()).isEqualTo(4242L);
+ assertThat(decoded.idle()).isEqualTo(idle);
+ assertThat(decoded.committables()).isEmpty();
+ }
+ }
+
+ @Test
+ public void testV1PayloadDeserializesAsActive() throws IOException {
+ // Hand-encode a v1 payload (no idle bit) so the reader is exercised
on real bytes rather
+ // than a spec that could drift alongside the serializer
implementation.
+ DataOutputSerializer out = new DataOutputSerializer(32);
+ out.writeLong(7L); // checkpointId
+ out.writeLong(1234L); // watermark
+ out.writeInt(new CommittableSerializer(new
CommitMessageSerializer()).getVersion());
+ out.writeInt(0); // empty committables
+
+ CheckpointCommittables decoded = serializer.deserialize(1,
out.getCopyOfBuffer());
+ assertThat(decoded.checkpointId()).isEqualTo(7L);
+ assertThat(decoded.watermark()).isEqualTo(1234L);
+ // v1 predates idle tracking; readers must default to ACTIVE so
pre-upgrade payloads keep
+ // participating in the min just like they did before.
+ assertThat(decoded.idle()).isFalse();
+ assertThat(decoded.committables()).isEmpty();
+ }
+
+ @Test
+ public void testUnknownVersionRejected() throws IOException {
+ byte[] bytes =
+ serializer.serialize(new CheckpointCommittables(1L,
Collections.emptyList(), 0L));
+ assertThatThrownBy(() -> serializer.deserialize(99, bytes))
+ .isInstanceOf(IOException.class)
+ .hasMessageContaining("Unknown version");
+ }
+}
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinatorTest.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinatorTest.java
index 5889f41cf5..a43bba18d3 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinatorTest.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/CommittingWriteOperatorCoordinatorTest.java
@@ -552,6 +552,33 @@ public class CommittingWriteOperatorCoordinatorTest
extends CommitterOperatorTes
coordinator.close();
}
+ /**
+ * When one subtask marks itself idle, the coordinator must skip that
subtask and take the min
+ * only over the remaining active subtasks — mirroring Flink's {@code
StatusWatermarkValve}.
+ */
+ @Timeout(value = 30, unit = TimeUnit.SECONDS)
+ @Test
+ public void testAlignmentSkipsIdleSubtaskWhenSomeActive() throws Exception
{
+ FileStoreTable table = createUnawareBucketTable();
+ TestingContext context = new TestingContext(new OperatorID(), 2);
+ CommittingWriteOperatorCoordinator coordinator =
createCoordinator(table, context, false);
+ coordinator.start();
+ coordinator.waitProcessAllActions();
+
+ // cp1: subtask-0 IDLE at watermark=100, subtask-1 ACTIVE with data +
watermark=500.
+ // Without idle handling this would emit min=100 and hold the snapshot
back to 100.
+ // With idle handling subtask-0 is excluded and the snapshot watermark
advances to 500.
+ coordinator.handleEventFromOperator(0, 0, idleEvent(1L, 100L));
+ coordinator.handleEventFromOperator(1, 0, event(500L,
committable(table, 1, 1)));
+ coordinator.notifyCheckpointComplete(1L);
+ coordinator.waitProcessAllActions();
+
+ Snapshot snapshot = table.snapshotManager().latestSnapshot();
+ assertThat(snapshot).isNotNull();
+ assertThat(snapshot.watermark()).isEqualTo(500L);
+ coordinator.close();
+ }
+
@Timeout(value = 30, unit = TimeUnit.SECONDS)
@Test
public void testPollManifestCommittablesForCheckpoint() throws Exception {
@@ -662,7 +689,9 @@ public class CommittingWriteOperatorCoordinatorTest extends
CommitterOperatorTes
checkpointId2,
writerCommittables,
CommittingWriteOperatorCoordinator.alignWatermarkPerCheckpoint(
- checkpointId2, writerCommittables),
+ checkpointId2,
+ writerCommittables,
+ new
WatermarkAligner(writerCommittables.length)),
committer);
BinaryRow partition = new BinaryRow(1);
@@ -751,13 +780,13 @@ public class CommittingWriteOperatorCoordinatorTest
extends CommitterOperatorTes
Map<Long, Long> upToCp1 =
CommittingWriteOperatorCoordinator.alignWatermarkPerCheckpoint(
- 1L, writerCommittables);
+ 1L, writerCommittables, new
WatermarkAligner(writerCommittables.length));
assertThat(upToCp1).hasSize(1);
assertThat(upToCp1.get(1L)).isEqualTo(100L);
Map<Long, Long> upToCp2 =
CommittingWriteOperatorCoordinator.alignWatermarkPerCheckpoint(
- 2L, writerCommittables);
+ 2L, writerCommittables, new
WatermarkAligner(writerCommittables.length));
assertThat(upToCp2).hasSize(2);
assertThat(upToCp2.get(1L)).isEqualTo(100L);
assertThat(upToCp2.get(2L)).isEqualTo(400L);
@@ -1180,6 +1209,14 @@ public class CommittingWriteOperatorCoordinatorTest
extends CommitterOperatorTes
SERIALIZER);
}
+ private CommittableEvent idleEvent(long checkpointId, long watermark)
throws Exception {
+ return CommittableEvent.create(
+ checkpointId,
+ new CheckpointCommittables(
+ checkpointId, Collections.emptyList(), watermark, /*
idle */ true),
+ SERIALIZER);
+ }
+
private CommittableEvent emptyEvent(long checkpointId) throws Exception {
return eventOf(checkpointId, Collections.emptyList(), Long.MIN_VALUE);
}
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/WatermarkAlignerTest.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/WatermarkAlignerTest.java
new file mode 100644
index 0000000000..7aec09a48f
--- /dev/null
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/WatermarkAlignerTest.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.paimon.flink.sink.coordinator;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Unit tests for {@link WatermarkAligner}. */
+public class WatermarkAlignerTest {
+
+ private static final int PARALLELISM = 3;
+
+ @Test
+ public void testAllActiveTakesMinAcrossSubtasks() {
+ WatermarkAligner aligner = new WatermarkAligner(PARALLELISM);
+ assertThat(aligner.align(new SubtaskWatermark[] {active(100L),
active(200L), active(300L)}))
+ .isEqualTo(100L);
+ }
+
+ @Test
+ public void testMonotonicMinAcrossCheckpoints() {
+ WatermarkAligner aligner = new WatermarkAligner(PARALLELISM);
+ aligner.align(new SubtaskWatermark[] {active(100L), active(200L),
active(300L)});
+ // Min across subtasks for the next reading is 250 — greater than the
last emit of 100.
+ assertThat(aligner.align(new SubtaskWatermark[] {active(400L),
active(250L), active(500L)}))
+ .isEqualTo(250L);
+ }
+
+ @Test
+ public void testIdleSubtaskIsSkippedAndActiveMinAdvances() {
+ WatermarkAligner aligner = new WatermarkAligner(PARALLELISM);
+ aligner.align(new SubtaskWatermark[] {active(100L), active(200L),
active(300L)});
+ // subtask-0 goes IDLE; remaining actives push min to 400.
+ assertThat(aligner.align(new SubtaskWatermark[] {idle(100L),
active(400L), active(500L)}))
+ .isEqualTo(400L);
+ }
+
+ @Test
+ public void testAllIdleFlushesMaxThenLatches() {
+ WatermarkAligner aligner = new WatermarkAligner(PARALLELISM);
+ aligner.align(new SubtaskWatermark[] {active(100L), active(200L),
active(300L)});
+
+ // Everyone goes IDLE with their last known watermarks — flush max=300.
+ assertThat(aligner.align(new SubtaskWatermark[] {idle(100L),
idle(200L), idle(300L)}))
+ .isEqualTo(300L);
+
+ // Still all idle — flush must not re-fire, emission stays at 300.
+ assertThat(aligner.align(new SubtaskWatermark[] {idle(100L),
idle(200L), idle(350L)}))
+ .isEqualTo(300L);
+ }
+
+ @Test
+ public void testIdleThenActiveWithLagStaysHeldUntilCatchUp() {
+ WatermarkAligner aligner = new WatermarkAligner(PARALLELISM);
+ aligner.align(new SubtaskWatermark[] {active(100L), active(200L),
active(300L)});
+
+ // subtask-0 IDLE; actives advance min to 250.
+ assertThat(aligner.align(new SubtaskWatermark[] {idle(100L),
active(250L), active(400L)}))
+ .isEqualTo(250L);
+
+ // subtask-0 comes back ACTIVE at 150 — still lagging (< last=250) so
it stays unaligned;
+ // min over aligned actives is min(300, 500) = 300.
+ assertThat(aligner.align(new SubtaskWatermark[] {active(150L),
active(300L), active(500L)}))
+ .isEqualTo(300L);
+
+ // subtask-0 climbs to 260 (< last=300) — still lagging, aligned min =
320.
+ assertThat(aligner.align(new SubtaskWatermark[] {active(260L),
active(320L), active(600L)}))
+ .isEqualTo(320L);
+
+ // subtask-0 finally reaches 350 >= last=320, rejoins the aligned set;
new min=350.
+ assertThat(aligner.align(new SubtaskWatermark[] {active(350L),
active(400L), active(700L)}))
+ .isEqualTo(350L);
+ }
+
+ @Test
+ public void testAbsentSubtaskBlocksMinLikeNeverReported() {
+ WatermarkAligner aligner = new WatermarkAligner(PARALLELISM);
+ // Absent subtasks are represented by ACTIVE + Long.MIN_VALUE — the
same shape Flink's
+ // valve exposes for a channel that has never emitted a watermark.
Under that reading the
+ // min stays at Long.MIN_VALUE (never-reported subtasks block the min,
unlike idle ones).
+ assertThat(
+ aligner.align(
+ new SubtaskWatermark[] {
+ active(500L), active(Long.MIN_VALUE),
active(Long.MIN_VALUE)
+ }))
+ .isEqualTo(Long.MIN_VALUE);
+ }
+
+ private static SubtaskWatermark active(long watermark) {
+ return new SubtaskWatermark(watermark, false);
+ }
+
+ private static SubtaskWatermark idle(long watermark) {
+ return new SubtaskWatermark(watermark, true);
+ }
+}
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/WriterCommittablesTest.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/WriterCommittablesTest.java
index 74352dbed4..aa2b20c832 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/WriterCommittablesTest.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/sink/coordinator/WriterCommittablesTest.java
@@ -330,6 +330,28 @@ public class WriterCommittablesTest {
Long.MIN_VALUE))));
}
+ @Test
+ public void testIsIdleAtDistinguishesPresentAndAbsent() {
+ WriterCommittables committables =
+ new WriterCommittables(
+ new CheckpointCommittables(
+ 1L, Collections.emptyList(), 100L, /* idle */
true));
+ committables.mergeWith(
+ new WriterCommittables(
+ new CheckpointCommittables(
+ 2L, Collections.emptyList(), 200L, /* idle */
false)));
+
+ // present + idle=true
+ assertThat(committables.isIdleAt(1L)).isTrue();
+ assertThat(committables.watermarkAt(1L)).isEqualTo(100L);
+ // present + idle=false
+ assertThat(committables.isIdleAt(2L)).isFalse();
+ assertThat(committables.watermarkAt(2L)).isEqualTo(200L);
+ // absent: mirrors Flink valve's "channel exists but never reported" —
ACTIVE with MIN_VALUE
+ assertThat(committables.isIdleAt(3L)).isFalse();
+ assertThat(committables.watermarkAt(3L)).isEqualTo(Long.MIN_VALUE);
+ }
+
@Test
public void testDuplicateCheckpointIdRejected() {
CommitMessage commitMessage = createEmptyCommitMessage();