DE Mastery GitHub

Apache Flink · FLK-06

State, checkpoints & exactly-once — surviving a TaskManager crash

From your world. SOX accuracy & completeness: a ledger with no dropped and no double-posted entries, even across a crash

← All exercises

Survive a TaskManager crash with exactly-once ledger invariants

Write a Flink streaming job that processes a ledger of N entries and writes the id of each entry to an exactly-once sink. The grader runs your job on a real cluster and sends SIGKILL to a TaskManager in the middle of the run. The job recovers from its last checkpoint. Then the grader asserts the ledger invariants over the committed output: every id appears exactly once. There is no loss and no duplication.

Implement de.flk.LedgerJob with a main:

public static void main(String[] args) throws Exception
  • args[0] is the output directory URI (for example file:///tmp/....). Write committed files there.
  • The source is the bounded, replayable sequence 0 .. N-1. N is read from args[1] (use NumberSequenceSource(0, N-1)).

Your job must do these steps:

  1. Enable checkpointing with env.enableCheckpointing(intervalMs). Keep the interval small (for example a few hundred ms), so that a checkpoint completes before the crash. The default checkpointing mode is EXACTLY_ONCE. Flink snapshots keyed state and source offsets together.
  2. Build the pipeline from the NumberSequenceSource. Key the stream (for example by id % 4). Run it through a small KeyedProcessFunction that simulates work for each record (a tiny Thread.sleep). Then the job is still running when the grader kills the TaskManager. Emit each id as its decimal string.
  3. Write with a commit-on-checkpoint sink: a FileSink.forRowFormat(new Path(args[0]), new SimpleStringEncoder<String>("UTF-8")) with .withRollingPolicy(OnCheckpointRollingPolicy.build()). Each part file becomes visible only when the checkpoint that produced it completes. So Flink rolls back the output produced since the last complete checkpoint on restart (no duplicates).
  4. Call env.execute(...).

After the job finishes, the committed output (the non-hidden part files) must contain each id in 0 .. N-1 exactly once. This must be true even though the grader killed a TaskManager while the job was running.

Why the starter fails

The starter enables no checkpointing. It uses a sink with no commit-on-checkpoint policy. So after a crash, the output either loses records that were in flight or emits them again. The invariants break. Add the three ingredients above: the replayable source (already given), checkpointed state, and a committing sink.

Starter code

src/main/java/de/flk/LedgerJob.java
package de.flk;

import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.OpenContext;
import org.apache.flink.api.connector.source.lib.NumberSequenceSource;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.streaming.api.functions.sink.legacy.RichSinkFunction;
import org.apache.flink.util.Collector;

import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.Writer;

/**
 * Starter: AT-LEAST-ONCE, not exactly-once. The sink appends every record to a
 * file immediately — it does NOT commit on checkpoint and nothing is rolled back
 * on restart. After a mid-run crash, records processed since the last checkpoint
 * are replayed and appended AGAIN, so the committed output has DUPLICATES — the
 * "no duplication" invariant fails (G1).
 *
 * TODO: replace the eager sink with a commit-on-checkpoint sink
 * (FileSink.forRowFormat(...).withRollingPolicy(OnCheckpointRollingPolicy.build()))
 * so uncommitted output is rolled back on restart and the result is exactly-once.
 */
public class LedgerJob {
    public static void main(String[] args) throws Exception {
        String out = args[0];
        long n = Long.parseLong(args[1]);

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(2);

        DataStreamSource<Long> src = env.fromSource(
                new NumberSequenceSource(0, n - 1),
                WatermarkStrategy.noWatermarks(), "ledger-source");

        DataStream<String> rows = src
                .keyBy(x -> x % 4)
                .process(new KeyedProcessFunction<Long, Long, String>() {
                    @Override
                    public void processElement(Long v, Context ctx, Collector<String> o)
                            throws Exception {
                        try {
                            Thread.sleep(40);
                        } catch (InterruptedException ignored) {
                        }
                        o.collect(String.valueOf(v));
                    }
                })
                .name("ledger");

        // Eager, non-transactional sink: writes on every record, no rollback.
        rows.addSink(new RichSinkFunction<String>() {
            transient Writer w;

            @Override
            public void open(OpenContext c) throws Exception {
                int idx = getRuntimeContext().getTaskInfo().getIndexOfThisSubtask();
                String dir = out.replace("file://", "");
                w = new BufferedWriter(new FileWriter(dir + "/eager-" + idx + ".txt", true));
            }

            @Override
            public void invoke(String value, Context ctx) throws Exception {
                w.write(value + "\n");
                w.flush();
            }

            @Override
            public void close() throws Exception {
                if (w != null) {
                    w.close();
                }
            }
        });

        env.execute("ledger-job");
    }
}

Hints

Hint 1

Call env.enableCheckpointing(...) so operator state is snapshotted. The default checkpointing mode is EXACTLY_ONCE; a replayable bounded source (NumberSequenceSource) lets recovery rewind to the checkpointed position.

Hint 2

Use a FileSink with OnCheckpointRollingPolicy so files are only committed when a checkpoint completes. Output written since the last complete checkpoint is rolled back on restart, which is what prevents duplicates after the crash.

Hint 3

Write exactly one line per source id (its decimal string) so the grader can check no-loss / no-duplication over the committed output. Read the output directory from args[0].

Run it locally
uv run de start FLK-06/01-ledger-recovery
# edit workspace/FLK-06/01-ledger-recovery/
uv run de test