Apache Flink · FLK-12
Deploy & operate — upgrade a stateful job via savepoint without losing state
- 5 h
- ☁️free
- requires FLK-06
From your world. a dbt/pipeline deploy that must not lose accumulated state — upgrading the job while preserving the running totals, like a zero-downtime schema migration
Upgrade a stateful job via savepoint without losing state
A long-running job keeps a running count of the records that it processed, per key. You must implement that counter so that its state survives a savepoint upgrade. You stop the job with a savepoint and restart it from the savepoint. Then the count continues where it stopped. It does not reset.
Provided (do not edit)
Job.java— It wires the pipeline: a bounded source →keyBy→ yourCounter→ a file sink. It enables checkpointing. It assigns a stable.uid("counter-v1")to your operator, so a savepoint can map its state back. It takes two program args: the output file path andK(how many records the source emits).BoundedThenBlock.java— This is the source. It emits0 … K-1and then blocks forever. It keeps its emitted offset in operator state, so it resumes correctly after a savepoint.
What to implement — Counter.java
Write a KeyedProcessFunction<Integer, Long, Long>. It keeps a per-key running count in Flink-managed
keyed state. On each record, it writes the latest count to the output file:
public class Counter extends KeyedProcessFunction<Integer, Long, Long> {
private transient ValueState<Long> cnt; // managed keyed state
private final String outPath;
public Counter(String outPath) { this.outPath = outPath; }
@Override public void open(OpenContext ctx) {
// TODO: cnt = getRuntimeContext().getState(new ValueStateDescriptor<>("count", Long.class));
}
@Override public void processElement(Long v, Context ctx, Collector<Long> out) throws Exception {
// TODO: n = (cnt.value() == null ? 0 : cnt.value()) + 1; cnt.update(n);
// write n to outPath (overwrite); out.collect(n);
}
}
Do not keep the count in a plain long field. Flink does not snapshot instance fields, so the
count would reset to zero on restore. The count must not reset. This is the purpose of the exercise.
How you are graded (deterministic, no stopwatch)
The test runs a two-phase cycle on a real local Flink cluster:
- Start the job with
K=5. Your counter reaches 5. Take a savepoint and stop the job. - Restart the same job from the savepoint with
K=10. The source replays its restored offset and emits 5 more records. Suppose your state was truly restored. Then the counter continues 6…10 and ends at 10. Suppose the state was lost. Then the counter would end at 5.
The test asserts that the final count is 10. This is the invariant for no loss and preserved state. The test polls readiness through the cluster REST API and the output file. Nothing depends on wall-clock timing. (This runs on the native dist with no Docker. The managed/cloud equivalents are flagged ☁️free / 💲 and the test does not grade them.)
Starter code
package de.flk;
import org.apache.flink.api.common.state.ListState;
import org.apache.flink.api.common.state.ListStateDescriptor;
import org.apache.flink.runtime.state.FunctionInitializationContext;
import org.apache.flink.runtime.state.FunctionSnapshotContext;
import org.apache.flink.streaming.api.checkpoint.CheckpointedFunction;
import org.apache.flink.streaming.api.functions.source.legacy.SourceFunction;
/** Provided — do not edit. Emits 0..K-1 then blocks forever. Keeps its emitted
* offset in OPERATOR state so a savepoint resumes it where it left off (so
* after restore with a larger K it emits only the NEW records). */
public class BoundedThenBlock
implements SourceFunction<Long>, CheckpointedFunction {
private volatile boolean running = true;
private final int k;
private long next = 0;
private transient ListState<Long> offsetState;
public BoundedThenBlock(int k) {
this.k = k;
}
@Override
public void run(SourceContext<Long> ctx) throws Exception {
while (running) {
synchronized (ctx.getCheckpointLock()) {
if (next < k) {
ctx.collect(next);
next++;
}
}
if (next >= k) {
Thread.sleep(50);
}
}
}
@Override
public void cancel() {
running = false;
}
@Override
public void snapshotState(FunctionSnapshotContext ctx) throws Exception {
offsetState.clear();
offsetState.add(next);
}
@Override
public void initializeState(FunctionInitializationContext ctx) throws Exception {
offsetState = ctx.getOperatorStateStore()
.getListState(new ListStateDescriptor<>("offset", Long.class));
if (ctx.isRestored()) {
for (Long v : offsetState.get()) {
next = v;
}
}
}
}
package de.flk;
import org.apache.flink.api.common.functions.OpenContext;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
/** Starter — this keeps the count in a plain instance field, which is NOT part
* of Flink's managed state, so it resets to zero after a savepoint restore
* (the test ends at 5 instead of 10). Fix it with keyed ValueState — see
* README. */
public class Counter extends KeyedProcessFunction<Integer, Long, Long> {
private transient long n; // TODO: this is not managed state — use ValueState
private final String outPath;
public Counter(String outPath) {
this.outPath = outPath;
}
@Override
public void open(OpenContext ctx) {
n = 0;
}
@Override
public void processElement(Long value, Context ctx, Collector<Long> out)
throws Exception {
n++;
Files.write(Paths.get(outPath), (n + "\n").getBytes(),
StandardOpenOption.CREATE, StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING);
out.collect(n);
}
}
package de.flk;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.SingleOutputStreamOperator;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
/** Provided — do not edit. Wires the pipeline and assigns a stable uid() so the
* savepoint can map the Counter's state back on restore.
* Program args: [0] output file path, [1] K (records the source emits). */
public class Job {
public static void main(String[] args) throws Exception {
String outPath = args[0];
int k = Integer.parseInt(args[1]);
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(400);
env.setParallelism(1);
DataStream<Long> src = env.addSource(new BoundedThenBlock(k)).uid("src");
SingleOutputStreamOperator<Long> counted =
src.keyBy(x -> 0).process(new Counter(outPath)).uid("counter-v1");
counted.print();
env.execute("stateful-counter");
}
}
Hints
Hint 1
The running count must survive a savepoint, so it has to live in Flink-managed KEYED state, not a plain instance field. Get a ValueState<Long> from the runtime context in open(...).
Hint 2
In open(OpenContext): cnt = getRuntimeContext().getState(new ValueStateDescriptor<>("count", Long.class)); In processElement: read cnt.value() (null -> 0), add 1, cnt.update(n), and write n to the output file.
Hint 3
Do NOT use a transient long field — it is reinitialized on restore and the count resets. The provided Job.java already assigns a stable .uid() so your state can be mapped back from the savepoint.
uv run de start FLK-12/01-savepoint-upgrade
# edit workspace/FLK-12/01-savepoint-upgrade/
uv run de test