Skip to content

[FLINK-40517][checkpointing] Aggregate cross-channel watermarks on the recovery unspilling thread for fan-in rescale - #29064

Merged
1996fanrui merged 1 commit into
apache:masterfrom
1996fanrui:FLINK-40517
Sep 5, 2026
Merged

[FLINK-40517][checkpointing] Aggregate cross-channel watermarks on the recovery unspilling thread for fan-in rescale#29064
1996fanrui merged 1 commit into
apache:masterfrom
1996fanrui:FLINK-40517

Conversation

@1996fanrui

@1996fanrui 1996fanrui commented Sep 1, 2026

Copy link
Copy Markdown
Member

What is the purpose of the change

On the normal recovery path, when a fan-in rescale merges several old input channels into one new channel,
DemultiplexingRecordDeserializer aggregates the recovered non-record elements across the merged channels:
it emits the min watermark (suppressed until every merged channel has one) and ACTIVE if any channel is
active. With checkpointing during recovery enabled that aggregation moves to the unspilling thread, but
ChannelStateFilteringHandler passed watermarks and statuses through verbatim — so a high watermark from one
merged channel advanced the operator watermark prematurely and legitimate in-flight lower-timestamp records
were dropped as late (silent data loss), and an IDLE from one merged channel wrongly idled the whole channel.
This performs the same min-watermark / any-active aggregation on the unspilling thread.

Brief change log

  • [FLINK-40517] Aggregate the min watermark (held while any merged old channel is still uninitialized) and any-active watermark-status per new channel in ChannelStateFilteringHandler; NO_RESCALE and fan-out stay verbatim.

Verifying this change

This change added tests:

  • GateFilterHandlerTest cases for fan-in min-watermark merge (with hold-until-all) and any-active watermark-status; both fail on verbatim pass-through.

Does this pull request potentially affect one of the following parts:

  • Dependencies (does it add or upgrade a dependency): no
  • The public API, i.e., is any changed class annotated with @Public(Evolving): no
  • The serializers: no
  • The runtime per-record code paths (performance sensitive): no (recovery-time unspilling only)
  • Anything that affects deployment or recovery: yes (unaligned-checkpoint restore with in-flight channel state on rescale)
  • The S3 file system connector: no

Documentation

  • Does this pull request introduce a new feature? no
  • If yes, how is the feature documented? not applicable

Was generative AI tooling used to co-author this PR?
  • Yes (please specify the tool below)

@flinkbot

flinkbot commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands The @flinkbot bot supports the following commands:
  • @flinkbot run azure re-run the last Azure build

Comment on lines +385 to +388
Watermark minWatermark =
mergeGroup.stream()
.map(VirtualChannel::getLastWatermark)
.min(Comparator.comparingLong(Watermark::getTimestamp))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we do this on the hot path, should we avoid:

  1. O(N)
  2. stream/conversion
    ?

The former might be premature optimisation, but the latter I think would be an easy change

ditto: anyActive below

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — both switched to plain loops.

O(N)

It's the recovery unspilling path and only runs per watermark/status (not per record), so low risk. Let us kept O(N) as-is first.

Comment on lines +236 to +253
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, Integer> keyToNewChannel = new HashMap<>();
gateVirtualChannels.forEach(
(key, vc) -> {
int newChannelIndex = newChannelIndexOf(key, oldToNewMapping);
byNewChannel.computeIfAbsent(newChannelIndex, k -> new ArrayList<>()).add(vc);
keyToNewChannel.put(key, newChannelIndex);
});

Map<SubtaskConnectionDescriptor, List<VirtualChannel<T>>> mergeGroups = new HashMap<>();
keyToNewChannel.forEach(
(key, newChannelIndex) -> mergeGroups.put(key, byNewChannel.get(newChannelIndex)));
return mergeGroups;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we write this transformation as

gateVirtualChannels
  .entrySet()
  .map(e -> newEntryWithNewKey(e)) // using newChannelIndexOf
  .collect(Collectors.groupingBy(MapEntry::getKey))

?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied for the grouping, thanks.

It yields Map<newChannelIndex, List<VC>>, so a second pass is still needed to re-key to old-channel key. Simplifies the front half but not the whole method.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, NIT:
I think it can be simplified further to

RescaleMappings oldToNew = 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, oldToNew), idx -> new ArrayList<>());
            group.add(vc);
            mergeGroups.put(key, group);
        });
return mergeGroups;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, and simplified!

@1996fanrui 1996fanrui left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks @rkhachatryan for the review, both are addressed.

Comment on lines +385 to +388
Watermark minWatermark =
mergeGroup.stream()
.map(VirtualChannel::getLastWatermark)
.min(Comparator.comparingLong(Watermark::getTimestamp))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — both switched to plain loops.

O(N)

It's the recovery unspilling path and only runs per watermark/status (not per record), so low risk. Let us kept O(N) as-is first.

Comment on lines +236 to +253
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, Integer> keyToNewChannel = new HashMap<>();
gateVirtualChannels.forEach(
(key, vc) -> {
int newChannelIndex = newChannelIndexOf(key, oldToNewMapping);
byNewChannel.computeIfAbsent(newChannelIndex, k -> new ArrayList<>()).add(vc);
keyToNewChannel.put(key, newChannelIndex);
});

Map<SubtaskConnectionDescriptor, List<VirtualChannel<T>>> mergeGroups = new HashMap<>();
keyToNewChannel.forEach(
(key, newChannelIndex) -> mergeGroups.put(key, byNewChannel.get(newChannelIndex)));
return mergeGroups;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied for the grouping, thanks.

It yields Map<newChannelIndex, List<VC>>, so a second pass is still needed to re-key to old-channel key. Simplifies the front half but not the whole method.

@rkhachatryan rkhachatryan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Comment on lines +236 to +253
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, Integer> keyToNewChannel = new HashMap<>();
gateVirtualChannels.forEach(
(key, vc) -> {
int newChannelIndex = newChannelIndexOf(key, oldToNewMapping);
byNewChannel.computeIfAbsent(newChannelIndex, k -> new ArrayList<>()).add(vc);
keyToNewChannel.put(key, newChannelIndex);
});

Map<SubtaskConnectionDescriptor, List<VirtualChannel<T>>> mergeGroups = new HashMap<>();
keyToNewChannel.forEach(
(key, newChannelIndex) -> mergeGroups.put(key, byNewChannel.get(newChannelIndex)));
return mergeGroups;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, NIT:
I think it can be simplified further to

RescaleMappings oldToNew = 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, oldToNew), idx -> new ArrayList<>());
            group.add(vc);
            mergeGroups.put(key, group);
        });
return mergeGroups;

…e recovery unspilling thread for fan-in rescale
@1996fanrui
1996fanrui merged commit 3593f08 into apache:master Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants