Guotao Ding created FLINK-41456:
-----------------------------------

             Summary: In-flight data of an unaligned checkpoint is silently 
dropped when its exchange becomes chained after a parallelism change
                 Key: FLINK-41456
                 URL: https://issues.apache.org/jira/browse/FLINK-41456
             Project: Flink
          Issue Type: Bug
          Components: Runtime / Checkpointing
    Affects Versions: 2.3.0, 1.20.5
            Reporter: Guotao Ding
         Attachments: uc-chained-data-loss.drawio.png

h2. Description
A job restored from an unaligned checkpoint with a different parallelism 
silently loses the in-flight data of a non-pointwise exchange (e.g. rebalance) 
when that exchange becomes a chained forward connection in the restored graph.

The edge in the example below is declared without an explicit partitioner. 
While the two parallelisms differ, Flink materializes it as a rebalance, which 
is why an unaligned checkpoint persists in-flight buffers for it. If the 
restore changes the parallelism so that both operators end up with the same 
parallelism, the same edge is materialized as a forward connection, the two 
operators are chained into a single {{JobVertex}}, and the exchange is folded 
into the chain: no network edge is left between them.

The in-flight data of that exchange is still in the checkpoint, but there is no 
exchange left to restore it to:

* The output state ({{ResultSubpartitionStateHandle}}) belongs to the former 
upstream tail, which is no longer the tail (output operator) of its vertex.
* The input state ({{InputChannelStateHandle}}) belongs to the former 
downstream head, which is no longer the head (input operator) of its vertex.

*Expected:* the restore is either rejected, or every record still reaches the 
sink.

*Actual:* the restore completes without an error and without a warning. The 
persisted records are dropped, and nothing in the logs, metrics or the job 
status indicates it.

Example: {{source(3) \-rebalance-> map(2) \-forward-> sink(2)}} is restored 
with {{source(2)}}. All three operators now have parallelism 2 and are chained 
into one vertex:

!uc-chained-data-loss.drawio.png!

h2. Root cause
{{StateAssignmentOperation}} already has a check for exactly this case. 
{{checkForUnsupportedToplogyChanges}} (sic) fails with:

{noformat}
Cannot recover from unaligned checkpoint when topology changes, such that data 
exchanges with
persisted data are now chained.{noformat}
However, the check is never reached. In 
{{reDistributeResultSubpartitionStates}} and {{reDistributeInputChannelStates}} 
it runs after an early return:

{code:java}
public void reDistributeResultSubpartitionStates(TaskStateAssignment 
assignment) {
    // FLINK-31963: We can skip this phase if there is no output state AND 
downstream has no
    // input states
    if (!assignment.hasOutputState() && !assignment.hasDownstreamInputStates()) 
{
        return;
    }

    checkForUnsupportedToplogyChanges(
            assignment.oldState,
            OperatorSubtaskState::getResultSubpartitionState,
            assignment.outputOperatorID);
    ...
}
{code}
{{hasOutputState()}} and {{hasDownstreamInputStates()}} only look at the output 
(tail) and input (head) operator of the vertex. When the in-flight data is 
owned by an operator that is chained in the new graph, that operator is neither 
the tail nor the head, so both conditions are false, the method returns early, 
and the check that would have caught the case is skipped.

The early return was introduced by FLINK-19801 and later extended by 
FLINK-31963. In both versions it only looks at the output (tail) and input 
(head) operator, so the check placed after it is skipped for this case.

h2. How to reproduce
The following ITCase runs {{source(3) \-> map(2) \-> sink(2)}} with unaligned 
checkpoints and a slow map, waits for a checkpoint with in-flight buffers, 
cancels the job and restores it as {{source(2) \-> map(2) \-> sink(2)}}. The 
edge is declared without a partitioner, so phase 1 sees a rebalance 
(parallelisms differ) and phase 2 sees a forward connection (parallelisms 
match). The sink keeps all received records in operator state, so after the 
restored job finishes, every record of the bounded source must be present.

On {{release-1.20}} ({{9957486cbfc}}) the test fails, e.g.:

{noformat}
[2947 of 50000 records were lost after recovering from the unaligned 
checkpoint]{noformat}
The number of lost records varies with the amount of in-flight data in the 
checkpoint.

The test is written against {{release-1.20}}. It uses the legacy 
{{SinkFunction}} API, which no longer exists on master; the affected code path 
in {{StateAssignmentOperation}} is unchanged on master.

{code:java}
package org.apache.flink.test.checkpointing;

// imports omitted

/**
 * Phase 1 runs {@code source(3) -> map(2) -> sink(2)}: the source to map 
exchange is a rebalance,
 * so the checkpoint persists in-flight buffers for it. Phase 2 restores with 
{@code source(2) ->
 * map(2) -> sink(2)}, where the same exchange becomes a forward connection 
inside a single chained
 * task. The recovery must either be rejected, or every record must still reach 
the sink.
 */
public class UnalignedCheckpointChainedExchangeITCase extends TestLogger {

    private static final long NUM_RECORDS = 50_000;
    private static final Set<Long> RESULTS = ConcurrentHashMap.newKeySet();

    private static MiniClusterWithClientResource cluster;
    @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();

    @Before
    public void setup() throws Exception {
        RESULTS.clear();
        cluster =
                new MiniClusterWithClientResource(
                        new MiniClusterResourceConfiguration.Builder()
                                .setNumberTaskManagers(1)
                                .setNumberSlotsPerTaskManager(10)
                                .build());
        cluster.before();
    }

    @After
    public void shutDownExistingCluster() {
        if (cluster != null) {
            cluster.after();
            cluster = null;
        }
    }

    @Test
    public void testNoInFlightDataLossWhenExchangeBecomesChained() throws 
Exception {
        final MiniCluster miniCluster = cluster.getMiniCluster();

        // Phase 1: source(3) -> map(2) is a rebalance exchange with in-flight 
data.
        final JobClient jobClient1 =
                executeJob(getEnv(null), 3, 
RateLimiterStrategy.perSecond(5000), true);
        CommonTestUtils.waitForJobStatus(jobClient1, 
Collections.singletonList(JobStatus.RUNNING));
        CommonTestUtils.waitForAllTaskRunning(miniCluster, 
jobClient1.getJobID(), false);
        final String checkpointPath =
                CommonTestUtils.waitForCheckpointWithInflightBuffers(
                        jobClient1.getJobID(), miniCluster);
        jobClient1.cancel().get();

        // Phase 2: source(2) -> map(2) becomes a forward exchange chained into 
one task.
        final JobClient jobClient2 =
                executeJob(getEnv(checkpointPath), 2, 
RateLimiterStrategy.noOp(), false);
        try {
            jobClient2.getJobExecutionResult().get(5, TimeUnit.MINUTES);
        } catch (ExecutionException e) {
            assertThat(
                            ExceptionUtils.findThrowableWithMessage(
                                    e, "data exchanges with persisted data are 
now chained"))
                    .as("The recovery failed for an unexpected reason: %s", e)
                    .isPresent();
            return;
        }

        final List<Long> missing = new ArrayList<>();
        for (long i = 0; i < NUM_RECORDS; i++) {
            if (!RESULTS.contains(i)) {
                missing.add(i);
            }
        }
        assertThat(missing)
                .as(
                        "%d of %d records were lost after recovering from the 
unaligned checkpoint",
                        missing.size(), NUM_RECORDS)
                .isEmpty();
    }

    private StreamExecutionEnvironment getEnv(@Nullable String recoveryPath) 
throws IOException {
        final Configuration conf = new Configuration();
        conf.set(CheckpointingOptions.CHECKPOINTING_INTERVAL, 
Duration.ofSeconds(1));
        conf.set(CheckpointingOptions.ALIGNED_CHECKPOINT_TIMEOUT, 
Duration.ofSeconds(0));
        conf.set(
                CheckpointingOptions.EXTERNALIZED_CHECKPOINT_RETENTION,
                ExternalizedCheckpointRetention.RETAIN_ON_CANCELLATION);
        conf.set(
                CheckpointingOptions.CHECKPOINTS_DIRECTORY,
                temporaryFolder.newFolder().toURI().toString());
        conf.set(CheckpointingOptions.ENABLE_UNALIGNED, true);
        conf.set(TaskManagerOptions.MEMORY_SEGMENT_SIZE, MemorySize.parse("1 
kb"));
        conf.set(CheckpointingOptions.MAX_RETAINED_CHECKPOINTS, 50);
        conf.set(RestartStrategyOptions.RESTART_STRATEGY, "none");
        if (recoveryPath != null) {
            conf.set(StateRecoveryOptions.SAVEPOINT_PATH, recoveryPath);
        }
        // Chaining stays enabled: the forward exchange of phase 2 must be 
chained.
        return StreamExecutionEnvironment.getExecutionEnvironment(conf);
    }

    private static JobClient executeJob(
            StreamExecutionEnvironment env,
            int sourceParallelism,
            RateLimiterStrategy rateLimiter,
            boolean slowMap)
            throws Exception {
        env.fromSource(
                        new DataGeneratorSource<>(
                                index -> index, NUM_RECORDS, rateLimiter, 
Types.LONG),
                        WatermarkStrategy.noWatermarks(),
                        "Source")
                .uid("source")
                .setParallelism(sourceParallelism)
                .map(
                        x -> {
                            if (slowMap) {
                                Thread.sleep(1);
                            }
                            return x;
                        })
                .uid("map")
                .setParallelism(2)
                .addSink(new CheckpointedCollectingSink())
                .uid("sink")
                .setParallelism(2);
        return env.executeAsync();
    }

    /** Keeps every received record in operator state and publishes them on 
finish. */
    private static class CheckpointedCollectingSink extends 
RichSinkFunction<Long>
            implements CheckpointedFunction {

        private final List<Long> received = new ArrayList<>();
        private transient ListState<Long> state;

        @Override
        public void invoke(Long value, Context context) {
            received.add(value);
        }

        @Override
        public void snapshotState(FunctionSnapshotContext context) throws 
Exception {
            state.update(received);
        }

        @Override
        public void initializeState(FunctionInitializationContext context) 
throws Exception {
            state =
                    context.getOperatorStateStore()
                            .getListState(new ListStateDescriptor<>("received", 
Types.LONG));
            if (context.isRestored()) {
                state.get().forEach(received::add);
            }
        }

        @Override
        public void finish() {
            RESULTS.addAll(received);
        }
    }
}
{code}
h2. Proposed fix
Run {{checkForUnsupportedToplogyChanges}} before the early return in both 
{{reDistributeResultSubpartitionStates}} and 
{{reDistributeInputChannelStates}}, and keep the short-circuit condition itself 
unchanged.

Why moving it is safe:

* The check only iterates over the checkpoint metadata on the JobManager. It 
does not read any state files, so it costs nothing on the restore path; the 
state files are still only touched for the operators that actually receive 
state.
* It only counts handles with offsets as data ({{getOffsets().isEmpty()}}), so 
a non-pointwise exchange that becomes forward without in-flight data is still 
restored as before. The short circuit keeps doing its job in that case.
* When the topology does not change, a chained operator carries no channel 
state of its own, so the check stays a no-op for the jobs that were already 
fine.

This makes the forward case consistent with a closely related one. If the same 
exchange becomes a rescale instead of a forward connection (e.g. 
{{source(4).rescale() \-> map(2)}}), the recovery already fails today, because 
the pointwise partitioner maps to {{SubtaskStateMapper.UNSUPPORTED}}:

{noformat}
Cannot rescale the given pointwise partitioner.{noformat}
In both cases the persisted in-flight data cannot be restored, so the recovery 
should fail explicitly instead of continuing without the data.



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to