Goal
Deploy a new version of a stateful Flink job (code change, connector bump or Flink version upgrade) and resume from exactly where the old one stopped. No reprocessing, no duplicates from the sink and a tested way back.
The things that go wrong are predictable: auto-generated operator IDs changed, a state type was serialized with Kryo and can't evolve, max parallelism changed, or the restore silently dropped state with --allowNonRestoredState. This runbook checks for each one before the cutover.
Before the change window
1. Make sure every stateful operator has a UID
Flink maps savepoint state to operators by operator ID. Without .uid(), the ID is a hash of the topology, so inserting a map upstream changes it and the state no longer matches.
env.fromSource(kafkaSource, watermarks, "orders-source").uid("orders-source")
.keyBy(Order::customerId)
.process(new CustomerTotals()).uid("customer-totals").name("customer-totals")
.sinkTo(kafkaSink).uid("totals-sink");
Enforce it so a missing UID fails at submit time instead of during the upgrade:
pipeline.auto-generate-uids: false
Warning
If the running job never had explicit UIDs, adding them now changes the IDs and the state will not map. Either set
.setUidHash("<existing hash>")on each operator for this one upgrade (read the hashes from the savepoint's operator list or the web UI vertex IDs), or plan a state rebuild.
2. Check state type compatibility
| State type | Schema evolution |
|---|---|
| POJOs (Flink POJO rules) | Add and remove fields; no type changes of existing fields |
| Avro (specific/generic) | Avro compatibility rules |
| Kryo fallback (generic types) | None in practice; changes often fail on restore |
| Key types | Must not change |
Find Kryo usage before it bites you:
pipeline.generic-types: false
With this set, the job fails at startup if any type falls back to Kryo. Run it in a test environment with the new build first.
3. Don't change max parallelism
Keyed state is split into key groups, and the number of key groups is the max parallelism. You can change parallelism up to that value on restore. You can't change max parallelism itself without rewriting the savepoint with the State Processor API. If it was never set explicitly, it was derived from the original parallelism. Set it explicitly now (setMaxParallelism) to the value the savepoint already uses.
4. Check where savepoints go
The savepoint target is a durable path (S3, ADLS, GCS, HDFS), not local disk. The config key is state.savepoints.dir in 1.x and execution.checkpointing.savepoint-dir in 2.x. Names vary by version, so check your build's docs. Confirm the job's identity has write access to it.
Cutover
5. Take a savepoint and stop the job
Stop-with-savepoint stops sources, takes a savepoint and commits pending sink transactions in one step:
flink list -r
flink stop --savepointPath s3://flink-state/savepoints/orders <jobId>
The command prints the savepoint path. Record it in the change ticket.
Caution
Don't add
--drainfor a normal upgrade. Draining emits a final MAX_WATERMARK, which fires all pending event-time windows and timers as if the stream had ended. The new job then has no open windows, and the results for in-flight windows are wrong. Use--drainonly when you are retiring the pipeline.
With the REST API (useful from CI):
curl -s -X POST http://jobmanager:8081/jobs/$JOB/stop \
-H 'Content-Type: application/json' \
-d '{"targetDirectory":"s3://flink-state/savepoints/orders","drain":false}'
# returns a request-id; poll it
curl -s http://jobmanager:8081/jobs/$JOB/savepoints/$REQUEST_ID
If stop-with-savepoint times out (backpressure, a stuck sink), the job keeps running. Fix the cause first; don't fall back to flink cancel, which gives you only the last checkpoint.
6. Start the new version from the savepoint
flink run -d -s s3://flink-state/savepoints/orders/savepoint-ab12cd-0123456789ab \
-p 8 orders-job-2.4.0.jar
Restore mode decides who owns the savepoint files:
| Mode | Behaviour | Use when |
|---|---|---|
NO_CLAIM (default) | New job takes a full checkpoint first and never depends on the savepoint files afterwards | Normal upgrades; keeps the savepoint valid for rollback |
CLAIM | New job takes ownership and may delete the files later | Large state where the first full checkpoint is too expensive |
The flag is --restoreMode in older releases and --claimMode in newer ones; check flink run --help on your version. Keep the default for upgrades so the rollback savepoint survives.
Warning
-n/--allowNonRestoredStatelets the job start even if some savepoint state has no matching operator. It's correct when you deliberately removed an operator. Otherwise it hides a UID mismatch and starts that operator empty. Never put it in a deploy script by default.
7. Flink Kubernetes Operator
With the operator, the same steps are driven by the FlinkDeployment spec:
spec:
job:
jarURI: local:///opt/flink/usrlib/orders-job-2.4.0.jar
parallelism: 8
upgradeMode: savepoint # stateless | last-state | savepoint
state: running
upgradeMode: savepoint makes the operator stop with a savepoint and restore from it when the spec changes. last-state uses the latest checkpoint and HA metadata. It's quicker, but it can't move between incompatible Flink versions. Check status.jobStatus and the savepoint info in the resource status after each change.
Validate
- Web UI or
GET /jobs/<id>/checkpoints: the first checkpoint completes and its size is similar to the old job's. - Restored state: per-operator state size in the checkpoint details isn't near zero for operators that should have state.
- Kafka source: consumer lag drops from the savepoint offsets; it doesn't jump to latest or earliest. A jump means the source state didn't restore and the connector fell back to its configured starting offsets.
- Kafka exactly-once sink: downstream
read_committedconsumers see data flowing. A changedtransactionalIdPrefixbetween versions can leave old transactions open untiltransaction.timeout.msexpires, which stalls those consumers. - Business check: compare an aggregate (count per key for the last hour) against the old version's output.
Rollback
flink stop --savepointPath ...the new version (or cancel it if it's unhealthy).- Start the old jar from the pre-upgrade savepoint (step 5), with
NO_CLAIM. - Expect the old version to reprocess everything since that savepoint. With an exactly-once Kafka sink, duplicates are fenced. With idempotent sinks (upserts by key) they are harmless. With append-only sinks, plan for downstream dedup.
Tip
A savepoint taken by a newer Flink version usually can't be restored by an older one. Before a Flink version upgrade, the pre-upgrade savepoint is your only rollback point. Keep it until the new version has run cleanly for a while.
Prevent it next time
- Fail builds on missing UIDs (
pipeline.auto-generate-uids: false) and Kryo fallback (pipeline.generic-types: false). - Set max parallelism explicitly on day one.
- Add a CI stage that restores the latest production savepoint (or a recent copy of it) into the new build and checks that it starts without
--allowNonRestoredState. - Take savepoints in canonical format (
flink savepoint --type canonical <jobId>) before Flink version upgrades, where the release notes require it. - Keep a small runbook entry per job: savepoint path, UIDs of stateful operators, max parallelism and sink transactional ID prefix.