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

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


The following commit(s) were added to refs/heads/master by this push:
     new bb773b5fb8a [FLINK-40475][runtime] Fix watermark loss and stall in 
StatusWatermarkValve when subpartitions realign after idleness (#29024)
bb773b5fb8a is described below

commit bb773b5fb8a4a87dd4efc3a8ae261b7e08e602a6
Author: Martijn Visser <[email protected]>
AuthorDate: Fri Aug 28 14:15:57 2026 +0200

    [FLINK-40475][runtime] Fix watermark loss and stall in StatusWatermarkValve 
when subpartitions realign after idleness (#29024)
    
    [FLINK-40475][runtime] Fix watermark loss and stall in StatusWatermarkValve 
when subpartitions realign after idleness
    
    The emission logic of StatusWatermarkValve relies on two invariants over
    the set of watermark-aligned subpartitions: it is only empty when no
    subpartition is active, and whenever it is non-empty its min watermark
    equals the last output watermark. Both were violated once an unaligned
    subpartition - one that went idle, resumed, and only partially caught up
    to the last output watermark - was involved:
    
    - The FLINK-7728 all-idle flush was skipped unless the last subpartition
      to become idle held the current min watermark. That assumption doesn't
      hold when unaligned subpartitions are involved, since an unaligned
      subpartition's watermark is invisible to the min-derive path. This made
      the final watermark depend on the order in which inputs became idle -
      exactly the defect FLINK-7728 was meant to fix.
    - The idle->active branch re-added a caught-up subpartition to the aligned
      set without re-deriving the min. When no other aligned subpartition
      remained, the realigned subpartition's watermark stalled indefinitely,
      emitted only once an even larger watermark arrived on it.
    
    Fix both by re-deriving from the aligned set unconditionally instead of
    relying on the broken shortcut:
    - Flush the max watermark unconditionally once all subpartitions go idle.
      findAndOutputMaxWatermarkAcrossAllSubpartitions only emits when the max
      advances past the last output watermark, so this cannot regress
      monotonicity, and it keeps the valve consistent with
      CombinedWatermarkStatus, which has flushed unconditionally since
      FLINK-38454.
    - Re-derive (and possibly emit) the min watermark after a subpartition
      realigns, after the ACTIVE status is emitted so downstream inputs don't
      drop the watermark while they still consider the input idle.
    
    Documents the invariants on alignedSubpartitionStatuses.
    
    Generated-by: Claude Code (claude-fable-5)
---
 .../watermarkstatus/StatusWatermarkValve.java      |  64 +++++-----
 .../watermarkstatus/StatusWatermarkValveTest.java  | 137 +++++++++++++++++++++
 2 files changed, 173 insertions(+), 28 deletions(-)

diff --git 
a/flink-runtime/src/main/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValve.java
 
b/flink-runtime/src/main/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValve.java
index 29f63c959d4..0109da532ec 100644
--- 
a/flink-runtime/src/main/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValve.java
+++ 
b/flink-runtime/src/main/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValve.java
@@ -69,7 +69,20 @@ public class StatusWatermarkValve {
     /** The last watermark status emitted from the valve. */
     private WatermarkStatus lastOutputWatermarkStatus;
 
-    /** A heap-based priority queue to help find the minimum watermark. */
+    /**
+     * A heap-based priority queue to help find the minimum watermark. It 
contains exactly the
+     * watermark-aligned subpartitions, for which the following invariants 
hold:
+     *
+     * <ul>
+     *   <li>only active subpartitions can be aligned
+     *   <li>the watermark of an aligned subpartition is never smaller than 
{@link
+     *       #lastOutputWatermark}
+     *   <li>whenever the queue is non-empty, its minimum watermark equals 
{@link
+     *       #lastOutputWatermark}; this is maintained by re-deriving the min 
watermark via {@link
+     *       #findAndOutputNewMinWatermarkAcrossAlignedSubpartitions} after 
every change to the
+     *       aligned set or to an aligned subpartition's watermark
+     * </ul>
+     */
     private final HeapPriorityQueue<SubpartitionStatus> 
alignedSubpartitionStatuses;
 
     /** Whether there are multiple subpartitions transmitted through the same 
input channel. */
@@ -221,30 +234,20 @@ public class StatusWatermarkValve {
             // if all subpartitions of the valve are now idle, we need to 
output an idle stream
             // status from the valve (this also marks the valve as idle)
             if 
(!SubpartitionStatus.hasActiveSubpartitions(subpartitionStatuses)) {
-
-                // now that all subpartitions are idle and no subpartitions 
will continue to advance
-                // its
-                // watermark,
-                // we should "flush" all watermarks across all subpartitions; 
effectively, this
-                // means
-                // emitting
-                // the max watermark across all subpartitions as the new 
watermark. Also, since we
-                // already try to advance
-                // the min watermark as subpartitions individually become 
IDLE, here we only need to
-                // perform the flush
-                // if the watermark of the last active subpartition that just 
became idle is the
-                // current
-                // min watermark.
-                if (subpartitionStatus.watermark == lastOutputWatermark) {
-                    findAndOutputMaxWatermarkAcrossAllSubpartitions(output);
-                }
+                // now that all subpartitions are idle and no subpartition 
will continue to advance
+                // its watermark, we should "flush" all watermarks across all 
subpartitions;
+                // effectively, this means emitting the max watermark across 
all subpartitions as
+                // the new watermark, so that the eventual watermark is 
independent of the order in
+                // which the subpartitions became idle. This also keeps the 
valve consistent with
+                // the unconditional flush in CombinedWatermarkStatus.
+                findAndOutputMaxWatermarkAcrossAllSubpartitions(output);
 
                 lastOutputWatermarkStatus = WatermarkStatus.IDLE;
                 output.emitWatermarkStatus(lastOutputWatermarkStatus);
-            } else if (subpartitionStatus.watermark == lastOutputWatermark) {
-                // if the watermark of the subpartition that just became idle 
equals the last output
-                // watermark (the previous overall min watermark), we may be 
able to find a new
-                // min watermark from the remaining aligned subpartitions
+            } else {
+                // the min watermark across the remaining aligned 
subpartitions may be larger than
+                // the last output watermark, now that the subpartition that 
just became idle is no
+                // longer part of the aligned set
                 findAndOutputNewMinWatermarkAcrossAlignedSubpartitions(output);
             }
         } else if (watermarkStatus.isActive() && 
subpartitionStatus.watermarkStatus.isIdle()) {
@@ -252,21 +255,26 @@ public class StatusWatermarkValve {
             subpartitionStatus.watermarkStatus = WatermarkStatus.ACTIVE;
 
             // if the last watermark of the subpartition, before it was marked 
idle, is still
-            // larger than
-            // the overall last output watermark of the valve, then we can set 
the subpartition to
-            // be
-            // aligned already.
+            // larger than the overall last output watermark of the valve, 
then we can set the
+            // subpartition to be aligned already.
             if (subpartitionStatus.watermark >= lastOutputWatermark) {
                 markWatermarkAligned(subpartitionStatus);
             }
 
             // if the valve was previously marked to be idle, mark it as 
active and output an active
-            // stream
-            // status because at least one of the subpartitions is now active
+            // stream status because at least one of the subpartitions is now 
active
             if (lastOutputWatermarkStatus.isIdle()) {
                 lastOutputWatermarkStatus = WatermarkStatus.ACTIVE;
                 output.emitWatermarkStatus(lastOutputWatermarkStatus);
             }
+
+            // the subpartition that just realigned may hold the new min 
watermark of the aligned
+            // set (e.g. if it is the only aligned subpartition), in which 
case its watermark must
+            // be emitted now; otherwise it would be stalled until an even 
larger watermark arrives
+            // on the subpartition. This must happen after the ACTIVE status 
was emitted, so that
+            // downstream inputs do not drop the watermark while they still 
consider this input
+            // idle.
+            findAndOutputNewMinWatermarkAcrossAlignedSubpartitions(output);
         }
     }
 
diff --git 
a/flink-runtime/src/test/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValveTest.java
 
b/flink-runtime/src/test/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValveTest.java
index e139f4c4866..e222d531def 100644
--- 
a/flink-runtime/src/test/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValveTest.java
+++ 
b/flink-runtime/src/test/java/org/apache/flink/streaming/runtime/watermarkstatus/StatusWatermarkValveTest.java
@@ -336,6 +336,53 @@ class StatusWatermarkValveTest {
         assertThat(valveOutput.popLastSeenOutput()).isNull();
     }
 
+    /**
+     * Tests that the max watermark across all channels is flushed once all 
inputs become idle, even
+     * when the last channel to become idle is unaligned, i.e. it went idle, 
resumed being active,
+     * and only partially caught up to the last output watermark. The eventual 
watermark advancement
+     * result must be independent of the order in which the inputs become idle 
(FLINK-7728).
+     */
+    @Test
+    void 
testMultipleInputFlushMaxWatermarkOnceAllInputsBecomeIdleWithUnalignedLastChannel()
+            throws Exception {
+        StatusWatermarkOutput valveOutput = new StatusWatermarkOutput();
+        StatusWatermarkValve valve = new StatusWatermarkValve(3);
+
+        valve.inputWatermark(new Watermark(100), 0, valveOutput);
+        valve.inputWatermark(new Watermark(50), 1, valveOutput);
+        valve.inputWatermark(new Watermark(70), 2, valveOutput);
+        assertThat(valveOutput.popLastSeenOutput()).isEqualTo(new 
Watermark(50));
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // channel 1 becomes idle; the new min watermark is computed from 
channels 0 and 2
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 1, valveOutput);
+        assertThat(valveOutput.popLastSeenOutput()).isEqualTo(new 
Watermark(70));
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // channel 1 resumes being active, but stays unaligned since its 
watermark (50) has not
+        // caught up to the last output watermark (70)
+        valve.inputWatermarkStatus(WatermarkStatus.ACTIVE, 1, valveOutput);
+        
assertThat(valve.getSubpartitionStatus(1).isWatermarkAligned).isFalse();
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // channel 1 partially catches up; its watermark is recorded but it 
remains unaligned
+        valve.inputWatermark(new Watermark(60), 1, valveOutput);
+        assertThat(valve.getSubpartitionStatus(1).watermark).isEqualTo(60);
+        
assertThat(valve.getSubpartitionStatus(1).isWatermarkAligned).isFalse();
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 0, valveOutput);
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 2, valveOutput);
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // the unaligned channel 1 is the last channel to become idle; the max 
watermark across
+        // all channels must still be flushed
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 1, valveOutput);
+        assertThat(valveOutput.popLastSeenOutput()).isEqualTo(new 
Watermark(100));
+        
assertThat(valveOutput.popLastSeenOutput()).isEqualTo(WatermarkStatus.IDLE);
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+    }
+
     /**
      * Tests that when idle channels become active again, they need to "catch 
up" with the latest
      * watermark before they are considered for min watermark computation 
again.
@@ -431,6 +478,96 @@ class StatusWatermarkValveTest {
         assertThat(valveOutput.popLastSeenOutput()).isNull();
     }
 
+    /**
+     * Tests that when a channel reactivates and realigns while no other 
aligned channels remain
+     * (all remaining channels are idle or unaligned), the min watermark 
across aligned channels is
+     * re-derived and emitted; otherwise the realigned channel's watermark 
would be stalled until an
+     * even higher watermark arrives on it.
+     */
+    @Test
+    void 
testWatermarkAdvancesWhenReactivatedChannelBecomesOnlyAlignedChannel() throws 
Exception {
+        StatusWatermarkOutput valveOutput = new StatusWatermarkOutput();
+        StatusWatermarkValve valve = new StatusWatermarkValve(3);
+
+        valve.inputWatermark(new Watermark(10), 0, valveOutput);
+        valve.inputWatermark(new Watermark(7), 1, valveOutput);
+
+        // channel 2 becomes idle; the new min watermark is computed from 
channels 0 and 1
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 2, valveOutput);
+        assertThat(valveOutput.popLastSeenOutput()).isEqualTo(new 
Watermark(7));
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // channel 2 resumes being active but is unaligned (its watermark has 
never advanced)
+        valve.inputWatermarkStatus(WatermarkStatus.ACTIVE, 2, valveOutput);
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // channels 0 and 1 become idle; channel 2 is the only active channel 
but it is unaligned,
+        // so no watermark can be emitted
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 0, valveOutput);
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 1, valveOutput);
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // channel 0 resumes being active; its watermark (10) has already 
caught up to the last
+        // output watermark (7), so it realigns and the min watermark must 
advance to 10
+        valve.inputWatermarkStatus(WatermarkStatus.ACTIVE, 0, valveOutput);
+        assertThat(valve.getSubpartitionStatus(0).isWatermarkAligned).isTrue();
+        assertThat(valveOutput.popLastSeenOutput()).isEqualTo(new 
Watermark(10));
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // the unaligned channel 2 becoming idle again must not change anything
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 2, valveOutput);
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+    }
+
+    /**
+     * Tests that when a channel reactivates and realigns with a watermark 
larger than the last
+     * output watermark, the min watermark across aligned channels is 
re-derived and emitted, and
+     * that the flush once all inputs become idle afterwards does not emit a 
duplicate watermark.
+     */
+    @Test
+    void testRealignmentAfterResumeActiveEmitsNewMinWatermark() throws 
Exception {
+        StatusWatermarkOutput valveOutput = new StatusWatermarkOutput();
+        StatusWatermarkValve valve = new StatusWatermarkValve(3);
+
+        valve.inputWatermark(new Watermark(10), 0, valveOutput);
+        valve.inputWatermark(new Watermark(20), 1, valveOutput);
+        valve.inputWatermark(new Watermark(30), 2, valveOutput);
+        assertThat(valveOutput.popLastSeenOutput()).isEqualTo(new 
Watermark(10));
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // channel 2 becomes idle; its watermark (30) is above the min, so 
nothing changes
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 2, valveOutput);
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // channel 0 becomes idle; the new min watermark is computed from 
channel 1
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 0, valveOutput);
+        assertThat(valveOutput.popLastSeenOutput()).isEqualTo(new 
Watermark(20));
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // channel 0 resumes being active, but stays unaligned (10 < 20)
+        valve.inputWatermarkStatus(WatermarkStatus.ACTIVE, 0, valveOutput);
+        
assertThat(valve.getSubpartitionStatus(0).isWatermarkAligned).isFalse();
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // channel 1 becomes idle; no aligned channels remain, so nothing can 
be emitted
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 1, valveOutput);
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // channel 2 resumes being active; its watermark (30) is above the 
last output watermark
+        // (20), so it realigns and the min watermark must advance to 30
+        valve.inputWatermarkStatus(WatermarkStatus.ACTIVE, 2, valveOutput);
+        assertThat(valve.getSubpartitionStatus(2).isWatermarkAligned).isTrue();
+        assertThat(valveOutput.popLastSeenOutput()).isEqualTo(new 
Watermark(30));
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+
+        // all channels become idle; the flush must not emit another 
watermark, since the max
+        // watermark across all channels (30) has already been emitted
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 0, valveOutput);
+        valve.inputWatermarkStatus(WatermarkStatus.IDLE, 2, valveOutput);
+        
assertThat(valveOutput.popLastSeenOutput()).isEqualTo(WatermarkStatus.IDLE);
+        assertThat(valveOutput.popLastSeenOutput()).isNull();
+    }
+
     private static class StatusWatermarkOutput implements 
PushingAsyncDataInput.DataOutput {
 
         private BlockingQueue<StreamElement> allOutputs = new 
LinkedBlockingQueue<>();

Reply via email to