This is an automated email from the ASF dual-hosted git repository.
1996fanrui 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 3593f080f09 [FLINK-40517][checkpointing] Aggregate cross-channel
watermarks on the recovery unspilling thread for fan-in rescale
3593f080f09 is described below
commit 3593f080f09fb9f974dca62279a98cc16505acb9
Author: Rui Fan <[email protected]>
AuthorDate: Mon Aug 31 17:44:43 2026 +0200
[FLINK-40517][checkpointing] Aggregate cross-channel watermarks on the
recovery unspilling thread for fan-in rescale
---
.../channel/ChannelStateFilteringHandler.java | 108 ++++++++++++++-
.../GateFilterHandlerBufferOwnershipTest.java | 4 +-
.../checkpoint/channel/GateFilterHandlerTest.java | 152 +++++++++++++++++++--
.../InputChannelRecoveredStateHandlerTest.java | 3 +-
4 files changed, 251 insertions(+), 16 deletions(-)
diff --git
a/flink-runtime/src/main/java/org/apache/flink/runtime/checkpoint/channel/ChannelStateFilteringHandler.java
b/flink-runtime/src/main/java/org/apache/flink/runtime/checkpoint/channel/ChannelStateFilteringHandler.java
index 0b6976068d7..be9c91e4955 100644
---
a/flink-runtime/src/main/java/org/apache/flink/runtime/checkpoint/channel/ChannelStateFilteringHandler.java
+++
b/flink-runtime/src/main/java/org/apache/flink/runtime/checkpoint/channel/ChannelStateFilteringHandler.java
@@ -30,12 +30,14 @@ import org.apache.flink.runtime.io.network.buffer.Buffer;
import org.apache.flink.runtime.io.network.partition.consumer.InputGate;
import org.apache.flink.runtime.plugable.DeserializationDelegate;
import org.apache.flink.runtime.plugable.NonReusingDeserializationDelegate;
+import org.apache.flink.streaming.api.watermark.Watermark;
import org.apache.flink.streaming.runtime.io.recovery.RecordFilter;
import org.apache.flink.streaming.runtime.io.recovery.RecordFilterContext;
import org.apache.flink.streaming.runtime.io.recovery.VirtualChannel;
import
org.apache.flink.streaming.runtime.io.recovery.VirtualChannelRecordFilterFactory;
import org.apache.flink.streaming.runtime.streamrecord.StreamElement;
import org.apache.flink.streaming.runtime.streamrecord.StreamElementSerializer;
+import org.apache.flink.streaming.runtime.watermarkstatus.WatermarkStatus;
import javax.annotation.Nullable;
@@ -47,6 +49,7 @@ import java.util.List;
import java.util.Map;
import static org.apache.flink.util.Preconditions.checkNotNull;
+import static org.apache.flink.util.Preconditions.checkState;
/**
* Filters recovered channel state buffers during the channel-state-unspilling
phase, removing
@@ -221,7 +224,47 @@ public class ChannelStateFilteringHandler implements
Closeable {
return null;
}
- return new GateFilterHandler<>(gateVirtualChannels, elementSerializer);
+ return new GateFilterHandler<>(gateVirtualChannels, elementSerializer,
channelMapping);
+ }
+
+ /**
+ * Maps each old-channel key to the virtual channels that fold into its
new channel, so
+ * watermarks/statuses can be aggregated across old channels merged on a
fan-in rescale. Without
+ * a rescale each group is a singleton and aggregation is verbatim
pass-through.
+ */
+ private static <T>
+ Map<SubtaskConnectionDescriptor, List<VirtualChannel<T>>>
buildWatermarkMergeGroups(
+ Map<SubtaskConnectionDescriptor, VirtualChannel<T>>
gateVirtualChannels,
+ RescaleMappings channelMapping) {
+ RescaleMappings oldToNewMapping = channelMapping.invert();
+ Map<Integer, List<VirtualChannel<T>>> byNewChannel = new HashMap<>();
+ Map<SubtaskConnectionDescriptor, List<VirtualChannel<T>>> mergeGroups
= new HashMap<>();
+ gateVirtualChannels.forEach(
+ (key, vc) -> {
+ List<VirtualChannel<T>> group =
+ byNewChannel.computeIfAbsent(
+ newChannelIndexOf(key, oldToNewMapping),
+ idx -> new ArrayList<>());
+ group.add(vc);
+ mergeGroups.put(key, group);
+ });
+ return mergeGroups;
+ }
+
+ /**
+ * Resolves the new channel index the given old-channel descriptor is
routed to. Its {@code
+ * outputSubtaskIndex} field carries the old channel index.
+ */
+ private static int newChannelIndexOf(
+ SubtaskConnectionDescriptor oldChannelKey, RescaleMappings
oldToNewMapping) {
+ int oldChannelIndex = oldChannelKey.getOutputSubtaskIndex();
+ int[] mapped = oldToNewMapping.getMappedIndexes(oldChannelIndex);
+ checkState(
+ mapped.length == 1,
+ "One old channel is expected to fold into exactly one new
channel, but %s mapped to %s new channels.",
+ oldChannelKey,
+ mapped.length);
+ return mapped[0];
}
/**
@@ -252,14 +295,24 @@ public class ChannelStateFilteringHandler implements
Closeable {
static class GateFilterHandler<T> {
private final Map<SubtaskConnectionDescriptor, VirtualChannel<T>>
virtualChannels;
+
+ /**
+ * For each old-channel key, the virtual channels folding into the
same new channel, used to
+ * aggregate watermarks/statuses across old channels merged on a
fan-in rescale.
+ */
+ private final Map<SubtaskConnectionDescriptor, List<VirtualChannel<T>>>
+ watermarkMergeGroups;
+
private final StreamElementSerializer<T> serializer;
private final DeserializationDelegate<StreamElement>
deserializationDelegate;
GateFilterHandler(
Map<SubtaskConnectionDescriptor, VirtualChannel<T>>
virtualChannels,
- StreamElementSerializer<T> serializer) {
+ StreamElementSerializer<T> serializer,
+ RescaleMappings channelMapping) {
this.virtualChannels = checkNotNull(virtualChannels);
this.serializer = checkNotNull(serializer);
+ this.watermarkMergeGroups =
buildWatermarkMergeGroups(virtualChannels, channelMapping);
this.deserializationDelegate = new
NonReusingDeserializationDelegate<>(serializer);
}
@@ -288,13 +341,21 @@ public class ChannelStateFilteringHandler implements
Closeable {
+ virtualChannels.keySet());
}
+ List<VirtualChannel<T>> mergeGroup =
watermarkMergeGroups.get(key);
+ checkNotNull(mergeGroup, "No watermark merge group for key:
%s", key);
+
vc.setNextBuffer(sourceBuffer);
sourceBufferOwnershipTransferred = true;
while (true) {
DeserializationResult result =
vc.getNextRecord(deserializationDelegate);
if (result.isFullRecord()) {
-
serializeElement(deserializationDelegate.getInstance(), outputSerializer);
+ // vc.getNextRecord has already updated the source
channel's lastWatermark /
+ // watermarkStatus, so aggregation below reads the
up-to-date group state.
+ emitAggregated(
+ deserializationDelegate.getInstance(),
+ mergeGroup,
+ outputSerializer);
}
if (result.isBufferConsumed()) {
break;
@@ -308,6 +369,47 @@ public class ChannelStateFilteringHandler implements
Closeable {
}
}
+ /**
+ * Writes one element, aggregating non-records across the {@code
mergeGroup}: emit the group
+ * min watermark (suppressed until every merged channel has one), and
{@code ACTIVE} status
+ * while any merged channel is active. Records and latency markers
pass through verbatim.
+ */
+ private void emitAggregated(
+ StreamElement element,
+ List<VirtualChannel<T>> mergeGroup,
+ DataOutputSerializer outputSerializer)
+ throws IOException {
+ if (element.isWatermark()) {
+ Watermark minWatermark = null;
+ for (VirtualChannel<T> channel : mergeGroup) {
+ Watermark candidate = channel.getLastWatermark();
+ if (minWatermark == null
+ || candidate.getTimestamp() <
minWatermark.getTimestamp()) {
+ minWatermark = candidate;
+ }
+ }
+ checkState(minWatermark != null, "Should always have a
watermark");
+ // min == UNINITIALIZED only when some merged old channel has
no watermark yet;
+ // hold the group's watermark back until every one of them has
produced one.
+ if (!minWatermark.equals(Watermark.UNINITIALIZED)) {
+ serializeElement(minWatermark, outputSerializer);
+ }
+ } else if (element.isWatermarkStatus()) {
+ boolean anyActive = false;
+ for (VirtualChannel<T> channel : mergeGroup) {
+ if (channel.getWatermarkStatus().isActive()) {
+ anyActive = true;
+ break;
+ }
+ }
+ serializeElement(
+ anyActive ? WatermarkStatus.ACTIVE :
element.asWatermarkStatus(),
+ outputSerializer);
+ } else {
+ serializeElement(element, outputSerializer);
+ }
+ }
+
/**
* Appends one stream element as a length-prefixed record. Reserves
the 4B prefix,
* serializes the element, then backfills the length, because {@code
outputSerializer}
diff --git
a/flink-runtime/src/test/java/org/apache/flink/runtime/checkpoint/channel/GateFilterHandlerBufferOwnershipTest.java
b/flink-runtime/src/test/java/org/apache/flink/runtime/checkpoint/channel/GateFilterHandlerBufferOwnershipTest.java
index ae7b722f683..206ed08e204 100644
---
a/flink-runtime/src/test/java/org/apache/flink/runtime/checkpoint/channel/GateFilterHandlerBufferOwnershipTest.java
+++
b/flink-runtime/src/test/java/org/apache/flink/runtime/checkpoint/channel/GateFilterHandlerBufferOwnershipTest.java
@@ -21,6 +21,7 @@ import
org.apache.flink.api.common.typeutils.base.LongSerializer;
import org.apache.flink.core.memory.DataOutputSerializer;
import org.apache.flink.core.memory.MemorySegment;
import org.apache.flink.core.memory.MemorySegmentFactory;
+import org.apache.flink.runtime.checkpoint.RescaleMappings;
import org.apache.flink.runtime.io.network.api.SubtaskConnectionDescriptor;
import
org.apache.flink.runtime.io.network.api.serialization.RecordDeserializer;
import
org.apache.flink.runtime.io.network.api.serialization.SpillingAdaptiveSpanningRecordDeserializer;
@@ -142,7 +143,8 @@ class GateFilterHandlerBufferOwnershipTest {
StreamElementSerializer<Long> serializer =
new StreamElementSerializer<>(LongSerializer.INSTANCE);
- return new ChannelStateFilteringHandler.GateFilterHandler<>(channels,
serializer);
+ return new ChannelStateFilteringHandler.GateFilterHandler<>(
+ channels, serializer, RescaleMappings.SYMMETRIC_IDENTITY);
}
private Buffer createBufferWithRecords(Long... values) {
diff --git
a/flink-runtime/src/test/java/org/apache/flink/runtime/checkpoint/channel/GateFilterHandlerTest.java
b/flink-runtime/src/test/java/org/apache/flink/runtime/checkpoint/channel/GateFilterHandlerTest.java
index 1646908727a..44875cf86f0 100644
---
a/flink-runtime/src/test/java/org/apache/flink/runtime/checkpoint/channel/GateFilterHandlerTest.java
+++
b/flink-runtime/src/test/java/org/apache/flink/runtime/checkpoint/channel/GateFilterHandlerTest.java
@@ -21,6 +21,7 @@ import
org.apache.flink.api.common.typeutils.base.LongSerializer;
import org.apache.flink.core.memory.DataOutputSerializer;
import org.apache.flink.core.memory.MemorySegment;
import org.apache.flink.core.memory.MemorySegmentFactory;
+import org.apache.flink.runtime.checkpoint.RescaleMappings;
import org.apache.flink.runtime.io.network.api.SubtaskConnectionDescriptor;
import
org.apache.flink.runtime.io.network.api.serialization.RecordDeserializer;
import
org.apache.flink.runtime.io.network.api.serialization.SpillingAdaptiveSpanningRecordDeserializer;
@@ -29,19 +30,23 @@ import
org.apache.flink.runtime.io.network.buffer.FreeingBufferRecycler;
import org.apache.flink.runtime.io.network.buffer.NetworkBuffer;
import org.apache.flink.runtime.plugable.DeserializationDelegate;
import org.apache.flink.runtime.plugable.NonReusingDeserializationDelegate;
+import org.apache.flink.streaming.api.watermark.Watermark;
import org.apache.flink.streaming.runtime.io.recovery.RecordFilter;
import org.apache.flink.streaming.runtime.io.recovery.VirtualChannel;
import org.apache.flink.streaming.runtime.streamrecord.StreamElement;
import org.apache.flink.streaming.runtime.streamrecord.StreamElementSerializer;
import org.apache.flink.streaming.runtime.streamrecord.StreamRecord;
+import org.apache.flink.streaming.runtime.watermarkstatus.WatermarkStatus;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -129,33 +134,104 @@ class GateFilterHandlerTest {
assertThat(sourceBuffer.isRecycled()).isTrue();
}
+ /**
+ * On a fan-in rescale two old channels A and B fold into one new channel:
A's high watermark is
+ * held while B has none, then the merged output carries the group
minimum, not A's higher one.
+ */
+ @Test
+ void testFanInWatermarkMinMergeAndHold() throws Exception {
+ SubtaskConnectionDescriptor keyA = new SubtaskConnectionDescriptor(0,
0);
+ SubtaskConnectionDescriptor keyB = new SubtaskConnectionDescriptor(0,
1);
+ ChannelStateFilteringHandler.GateFilterHandler<Long> handler =
+ createMergedHandler(keyA, keyB);
+
+ DataOutputSerializer output = new DataOutputSerializer(BUFFER_SIZE);
+
+ // A delivers a record then a high watermark; B has not produced a
watermark yet, so the
+ // watermark must be suppressed at this point.
+ handler.filterAndRewrite(
+ 0,
+ 0,
+ createBufferWithElements(new StreamRecord<>(1L), new
Watermark(100L)),
+ output);
+ // B delivers a record and a lower watermark; now the group can emit
min(100, 30) = 30.
+ handler.filterAndRewrite(
+ 0, 1, createBufferWithElements(new StreamRecord<>(2L), new
Watermark(30L)), output);
+
+ List<StreamElement> elements = readElementsFromSerializer(output);
+
+ // Both records pass through the filter.
+ assertThat(elements).filteredOn(StreamElement::isRecord).hasSize(2);
+ // The only watermark emitted is the min (30) — the premature 100 is
never written.
+ assertThat(elements)
+ .filteredOn(StreamElement::isWatermark)
+ .extracting(e -> e.asWatermark().getTimestamp())
+ .containsExactly(30L);
+ }
+
+ /**
+ * An {@code IDLE} watermark status from a single merged old channel must
not idle the whole new
+ * channel while another merged old channel is still active: the
aggregation emits {@code
+ * ACTIVE}.
+ */
+ @Test
+ void testFanInWatermarkStatusAnyActive() throws Exception {
+ SubtaskConnectionDescriptor keyA = new SubtaskConnectionDescriptor(0,
0);
+ SubtaskConnectionDescriptor keyB = new SubtaskConnectionDescriptor(0,
1);
+ ChannelStateFilteringHandler.GateFilterHandler<Long> handler =
+ createMergedHandler(keyA, keyB);
+
+ DataOutputSerializer output = new DataOutputSerializer(BUFFER_SIZE);
+
+ // A goes idle, but B is still active (default), so the merged status
stays ACTIVE.
+ handler.filterAndRewrite(0, 0,
createBufferWithElements(WatermarkStatus.IDLE), output);
+
+ List<StreamElement> elements = readElementsFromSerializer(output);
+ assertThat(elements).hasSize(1);
+ assertThat(elements.get(0).isWatermarkStatus()).isTrue();
+ assertThat(elements.get(0).asWatermarkStatus().isActive()).isTrue();
+ }
+
//
-------------------------------------------------------------------------------------------
// Helper methods
//
-------------------------------------------------------------------------------------------
- private ChannelStateFilteringHandler.GateFilterHandler<Long> createHandler(
- RecordFilter<Long> filter) {
- RecordDeserializer<DeserializationDelegate<StreamElement>>
deserializer =
- new SpillingAdaptiveSpanningRecordDeserializer<>(
- new String[] {System.getProperty("java.io.tmpdir")});
- VirtualChannel<Long> vc = new VirtualChannel<>(deserializer, filter);
+ /**
+ * Builds a handler whose two old channels {@code keyA} / {@code keyB}
fold into a single new
+ * channel (they share one watermark merge group), mirroring a fan-in
rescale.
+ */
+ private ChannelStateFilteringHandler.GateFilterHandler<Long>
createMergedHandler(
+ SubtaskConnectionDescriptor keyA, SubtaskConnectionDescriptor
keyB) {
+ VirtualChannel<Long> vcA = newVirtualChannel();
+ VirtualChannel<Long> vcB = newVirtualChannel();
Map<SubtaskConnectionDescriptor, VirtualChannel<Long>> channels = new
HashMap<>();
- channels.put(KEY, vc);
+ channels.put(keyA, vcA);
+ channels.put(keyB, vcB);
StreamElementSerializer<Long> serializer =
new StreamElementSerializer<>(LongSerializer.INSTANCE);
- return new ChannelStateFilteringHandler.GateFilterHandler<>(channels,
serializer);
+ // new channel 0 folds old channels {0, 1}, so both keys share one
watermark merge group.
+ RescaleMappings channelMapping = RescaleMappings.of(Stream.of(new
int[] {0, 1}), 2);
+ return new ChannelStateFilteringHandler.GateFilterHandler<>(
+ channels, serializer, channelMapping);
}
- private Buffer createBufferWithRecords(Long... values) throws IOException {
+ private VirtualChannel<Long> newVirtualChannel() {
+ RecordDeserializer<DeserializationDelegate<StreamElement>>
deserializer =
+ new SpillingAdaptiveSpanningRecordDeserializer<>(
+ new String[] {System.getProperty("java.io.tmpdir")});
+ return new VirtualChannel<>(deserializer, RecordFilter.acceptAll());
+ }
+
+ private Buffer createBufferWithElements(StreamElement... elements) throws
IOException {
StreamElementSerializer<Long> serializer =
new StreamElementSerializer<>(LongSerializer.INSTANCE);
DataOutputSerializer output = new DataOutputSerializer(BUFFER_SIZE);
- for (Long value : values) {
+ for (StreamElement element : elements) {
DataOutputSerializer recordOutput = new DataOutputSerializer(64);
- serializer.serialize(new StreamRecord<>(value), recordOutput);
+ serializer.serialize(element, recordOutput);
int recordLength = recordOutput.length();
output.writeInt(recordLength);
output.write(recordOutput.getSharedBuffer(), 0, recordLength);
@@ -170,6 +246,60 @@ class GateFilterHandlerTest {
return buffer;
}
+ private List<StreamElement>
readElementsFromSerializer(DataOutputSerializer output)
+ throws Exception {
+ List<StreamElement> elements = new ArrayList<>();
+ StreamElementSerializer<Long> serializer =
+ new StreamElementSerializer<>(LongSerializer.INSTANCE);
+ DeserializationDelegate<StreamElement> delegate =
+ new NonReusingDeserializationDelegate<>(serializer);
+
+ byte[] bodyBytes = output.getCopyOfBuffer();
+ if (bodyBytes.length == 0) {
+ return elements;
+ }
+ MemorySegment memSeg =
MemorySegmentFactory.allocateUnpooledSegment(bodyBytes.length);
+ memSeg.put(0, bodyBytes);
+ NetworkBuffer buf = new NetworkBuffer(memSeg,
FreeingBufferRecycler.INSTANCE);
+ buf.setSize(bodyBytes.length);
+
+
SpillingAdaptiveSpanningRecordDeserializer<DeserializationDelegate<StreamElement>>
+ deserializer =
+ new SpillingAdaptiveSpanningRecordDeserializer<>(
+ new String[]
{System.getProperty("java.io.tmpdir")});
+ deserializer.setNextBuffer(buf);
+
+ RecordDeserializer.DeserializationResult result;
+ do {
+ result = deserializer.getNextRecord(delegate);
+ if (result.isFullRecord()) {
+ elements.add(delegate.getInstance());
+ }
+ } while (!result.isBufferConsumed());
+ return elements;
+ }
+
+ private ChannelStateFilteringHandler.GateFilterHandler<Long> createHandler(
+ RecordFilter<Long> filter) {
+ RecordDeserializer<DeserializationDelegate<StreamElement>>
deserializer =
+ new SpillingAdaptiveSpanningRecordDeserializer<>(
+ new String[] {System.getProperty("java.io.tmpdir")});
+ VirtualChannel<Long> vc = new VirtualChannel<>(deserializer, filter);
+
+ Map<SubtaskConnectionDescriptor, VirtualChannel<Long>> channels = new
HashMap<>();
+ channels.put(KEY, vc);
+
+ StreamElementSerializer<Long> serializer =
+ new StreamElementSerializer<>(LongSerializer.INSTANCE);
+ return new ChannelStateFilteringHandler.GateFilterHandler<>(
+ channels, serializer, RescaleMappings.SYMMETRIC_IDENTITY);
+ }
+
+ private Buffer createBufferWithRecords(Long... values) throws IOException {
+ return createBufferWithElements(
+
Arrays.stream(values).map(StreamRecord::new).toArray(StreamElement[]::new));
+ }
+
private Buffer createEmptyBuffer() {
MemorySegment segment =
MemorySegmentFactory.allocateUnpooledSegment(BUFFER_SIZE);
return new NetworkBuffer(segment, FreeingBufferRecycler.INSTANCE);
diff --git
a/flink-runtime/src/test/java/org/apache/flink/runtime/checkpoint/channel/InputChannelRecoveredStateHandlerTest.java
b/flink-runtime/src/test/java/org/apache/flink/runtime/checkpoint/channel/InputChannelRecoveredStateHandlerTest.java
index 61fe42a6a4c..44050135904 100644
---
a/flink-runtime/src/test/java/org/apache/flink/runtime/checkpoint/channel/InputChannelRecoveredStateHandlerTest.java
+++
b/flink-runtime/src/test/java/org/apache/flink/runtime/checkpoint/channel/InputChannelRecoveredStateHandlerTest.java
@@ -508,7 +508,8 @@ class InputChannelRecoveredStateHandlerTest extends
RecoveredChannelStateHandler
channels.put(new SubtaskConnectionDescriptor(1,
channelInfo.getInputChannelIdx()), vc);
ChannelStateFilteringHandler.GateFilterHandler<Long> gateHandler =
- new
ChannelStateFilteringHandler.GateFilterHandler<>(channels, serializer);
+ new ChannelStateFilteringHandler.GateFilterHandler<>(
+ channels, serializer,
RescaleMappings.SYMMETRIC_IDENTITY);
return new ChannelStateFilteringHandler(
new ChannelStateFilteringHandler.GateFilterHandler<?>[]
{gateHandler});
}