High availability
StoatFlow gives you two ways to stay available, and you choose per application. By default an application is a single instance that recovers by restarting — Kubernetes brings the pod back and state rebuilds from the changelog. For workloads that can't absorb that restart window, you can opt into a hot standby: a cluster of one active instance plus one or more warm standbys, where a standby takes over in seconds. The single-instance default is unchanged; hot standby is an opt-in upgrade on top of it.
The two HA tiers
| Tier | What you get | Failover time | When to choose |
|---|---|---|---|
| Fast restart (default) | One instance. On failure Kubernetes restarts the pod and state rebuilds from the changelog. | Restart + state restore — scales with state size. | Most workloads, where a restart-window gap is acceptable. |
| Hot standby (opt-in) | One active plus one or more warm standbys. Each standby follows the changelog; on failover the freshest is elected and takes over. | Seconds, independent of state size. | Large state (tens of GB) or a tight recovery-time objective where a cold restore is too slow. |
Hot standby adds availability redundancy without changing the processing model, the exactly-once guarantees, or the single-active-instance shape. Everything the Architecture page describes still holds; you're keeping one or more warm spares ready, nothing more. The simplest cluster is one active and one standby — a two-replica StatefulSet, the default; add standbys for more redundancy (see desired-standbys).
How hot standby works
With ha.mode: active-standby you run two or more instances of the same application. At any moment one is the active — it owns the source partitions, processes records, and commits — and the rest are passive standbys. Each standby consumes the same changelog the active writes, keeping its own local state warm and a step behind, but it does not process source records or produce output. They coordinate through a compacted internal Kafka topic (auto-created; its name is configurable via stoatflow.ha.coordination-topic), exchanging a periodic heartbeat so each knows who is alive and which role they hold.
How the standby stays warm. A single changelog applier streams committed deltas into local state in real time — one consumer (in assign() mode, read_committed under exactly-once / read_uncommitted under at-least-once) and one virtual thread. It applies uniformly across RocksDB and in-memory stores and folds the derived-index rebuilds for foreign-key joins and suppression into the same loop, so the standby's state — primary stores and derived indices alike — tracks the active continuously rather than in periodic batches. Replication lag is read straight from that consumer's position, with no admin round-trip on the broker.
Promotion happens two ways:
- Graceful handoff — a rolling deploy, or an explicit
POST /ha/switch. The active drains its in-flight work, commits, and hands the role over to an elected standby. This is an in-place role swap: the demoted active rebuilds its engine as a standby inside the same pod and rejoins warm — no process exit, no cold state open, no pod restart. - Failover — the active stops heart-beating; the standbys notice, elect a successor, and it promotes itself.
Because the standby is already warm, taking over is a matter of seconds rather than a full cold restore. (A crash, a lost node, or a genuinely stuck restore still restarts the pod — the fence protects every path.)
The graceful path end to end — here triggered by an explicit POST /ha/switch; a rolling deploy runs the same sequence per pod:
Drain-to-LSO before processing. Whatever the trigger, a promoting pod first drains its applier to the committed end of the changelog — the log-stable-offset (LSO) — before it processes a single record. The departing active's producer is fenced eagerly, which freezes the LSO, so there is a fixed, finite target to drain to and no new committed deltas can appear past it. A caught-up standby drains almost nothing (it's already current); a standby that had fallen behind drains exactly the records it was missing — so it never resumes processing on stale state. This is what makes failover correct under exactly-once even when the standby was lagging at the moment the active died.
Electing the successor. With more than one standby, promotion is an election, not a free-for-all. Candidates are ranked by replication lag: a standby is eligible only while its total committed-changelog lag is at or below acceptable-recovery-lag, and the freshest eligible standby wins — an exact tie broken by a stable hash of the pod identity (nudged by failover-priority), so the choice always converges on exactly one. The winner claims a promotion token on the coordination topic before it fences the old active and restores; a standby that loses the claim simply stands down and stays a standby — no fence, no error, no restart. That ordering — elect and claim, then fence — is what stops two pods promoting at once in a multi-standby cluster. If the active is gone and no standby is within the threshold, the least-behind one is elected anyway after promotion-grace-ms (availability first) — still draining to the LSO before it processes. The producer-epoch fence stays the backstop: even if the election ever misfired, the broker still lets only one instance commit.
The same machinery under a crash — ungraceful failover from detection to takeover:
Processing guarantees under failover
How clean a failover is depends on the processing guarantee the application runs under.
- Exactly-once (the default). Failover is split-brain-proof. Kafka's transactional producer fencing — a broker-enforced guarantee — ensures at most one instance can ever commit a given transaction. If a network partition or a false failure-detection briefly leaves both instances believing they are active, only one can commit; the broker fences the other, which then shuts itself down. Downstream consumers reading
read_committedsee exactly-once output across the handoff: no duplicates, no lost work. - At-least-once. Failover is bounded-duplicate. The cluster still coordinates promotion safely — the promotion token is itself the fence under at-least-once — but without transactional fencing an ungraceful failover can re-process a small, bounded window of records, so a few duplicate output records are possible.
Configuration
Hot standby is configured under stoatflow.ha. The only required key is mode; everything else has a sensible default.
| Key | Default | Purpose |
|---|---|---|
stoatflow.ha.mode | off | off (single instance, fast restart) or active-standby (hot-standby cluster). |
stoatflow.ha.pod-id | POD_NAME env → HOSTNAME → local hostname | Stable per-pod identity that distinguishes the peers. |
stoatflow.ha.coordination-topic | __stoatflow_<applicationId>_ha | Single-partition, compacted, auto-created topic the cluster coordinates through. |
stoatflow.ha.desired-standbys | 1 | How many caught-up standby spares the readiness gate protects. 1 is the classic active/passive pair; raise it (with replicas) for more redundancy. Drives the redundancy floor on rolling deploys. |
stoatflow.ha.max-standbys | 3 | Hard ceiling on standbys, bounding changelog fan-out (each standby is one more changelog reader). Total instances ≤ max-standbys + 1. |
stoatflow.ha.failover-priority | 0 | Per-pod tie-break hint: orders candidates within the equal-lag bucket (higher wins). Never overrides lag or the hash floor. |
stoatflow.ha.promotion-settle-window-ms | 500 | After claiming the promotion token, how long the winner confirms it is the sole claimant before fencing — the single-promoter guarantee. Bounded 1..staleness-threshold-ms. |
stoatflow.ha.heartbeat-ms | 1000 | How often each pod publishes its liveness and role. |
stoatflow.ha.staleness-threshold-ms | 5000 | How long without a peer heartbeat before it's considered stale. Must be ≥ heartbeat-ms. |
stoatflow.ha.staleness-misses | 3 | Consecutive missed checks before a standby is elected to promote — a debounce against transient jitter. |
stoatflow.ha.acceptable-recovery-lag | 50000 | Total committed-changelog lag (in records, summed across all changelog partitions) at or below which a standby is considered caught up: it reports READY_STANDBY and is eligible to promote without the grace fallback. This is a single total sum, not per-task like Kafka Streams' acceptable.recovery.lag (10000) — so the KS number would be far too strict, and ~one large epoch can approach it. Correctness never depends on it — restore-before-process does; it's mostly a raise-me lever. |
stoatflow.ha.promotion-grace-ms | 5000 | When the active is gone and no standby is within acceptable-recovery-lag, the least-behind standby auto-promotes after this bounded grace window (availability-first). Eligibility is re-checked continuously during the window. |
stoatflow.ha.promotion-restore-no-progress-timeout-ms | 60000 | Progress-aware deadline for restore-before-process (bringing local state to the committed changelog end before processing): fails only if a full window of this length elapses with zero records applied (genuinely stuck), not the commit-path barrier timeout. On expiry the pod self-terminates and Kubernetes restarts it (the fence already prevents split-brain). |
stoatflow:
application-id: my-app
bootstrap-servers: kafka:9092
ha:
mode: active-standby # off (default) | active-standby
desired-standbys: 2 # 1 (default) = active/passive pair; here 1 active + 2 standbys
max-standbys: 3 # ceiling on standbys (bounds changelog fan-out)
# pod-id, coordination-topic, and the timing knobs all have sensible
# defaults — a two-replica pair sets only mode.
pod-id resolves automatically from the downward-API POD_NAME, so a typical deployment sets only mode.Coordination topic
The cluster coordinates through one compacted internal topic (stoatflow.ha.coordination-topic, default __stoatflow_<applicationId>_ha). It carries the per-pod heartbeats and the promotion token (the compare-and-set that serialises the election). It is auto-created on startup; the application's Kafka principal needs Describe + Read + Write on it, plus Create if you rely on auto-creation.
Two hard requirements:
- Exactly one partition. The single-partition total order is load-bearing for the promotion-token compare-and-set — never create it with more than one partition.
- Exclusive to one application. The default name is
applicationId-scoped for exactly this reason. If you overridecoordination-topic, give every application its own topic — sharing one across applications intermixes pod-status records and collides on the token, breaking the election and fencing.
Under at-least-once the topic is correctness-critical: the promotion token is itself the fence (ALO has no broker-enforced producer-epoch fence), so size its durability (replication factor, min.insync.replicas) accordingly. Under exactly-once the token is an availability layer — the election — and the fence is the producer epoch.
If your cluster denies topic auto-creation, pre-create the topic before starting the application. StoatFlow logs the exact required spec at ERROR and fails startup if the topic is missing and cannot be created:
| Property | Value |
|---|---|
| partitions | 1 (required — do not increase) |
cleanup.policy | compact |
retention.ms | -1 |
min.insync.replicas | 2 (1 on a single-broker cluster) |
| replication factor | 3 (or the broker count, if smaller) |
segment.ms | 300000 |
min.compaction.lag.ms | 0 |
max.compaction.lag.ms | 300000 — forces the active segment to roll and compact, bounding the heartbeat-churn log |
min.cleanable.dirty.ratio | 0.1 |
Deploying the cluster on Kubernetes
Run the cluster as a StatefulSet with two or more replicas (not a Deployment), keeping everything from the single-replica Kubernetes manifest — the state volume, license injection, config — and adjusting these points:
replicas: N— one active plusN-1passive standbys.replicas: 2is the classic pair (the default); the example below usesreplicas: 3for one active + two standbys.STOATFLOW__HA__DESIRED_STANDBYS/STOATFLOW__HA__MAX_STANDBYS— set alongsidereplicaswhen you run more than one standby (desired-standbysshould bereplicas - 1).updateStrategy: RollingUpdate— the roll is readiness-gated and order-independent (see below).volumeClaimTemplates— each pod gets its own persistent volume, so every standby holds warm state across restarts.- Split
/health/liveand/health/readyprobes. terminationGracePeriodSeconds≥ your graceful-handoff budget (epoch drain + final commit), so a draining active finishes its handoff beforeSIGKILL.POD_NAMEfrom the downward API →stoatflow.ha.pod-id.- A headless
Service(the StatefulSet'sserviceName).
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: my-stream-app
spec:
serviceName: my-stream-app # headless Service
replicas: 3 # one active + two passive standbys (replicas: 2 = the default pair)
updateStrategy:
type: RollingUpdate # readiness-gated; roll order doesn't matter
selector:
matchLabels:
app: my-stream-app
template:
metadata:
labels:
app: my-stream-app
spec:
# Must exceed the graceful-handoff budget so a draining active
# completes its handoff before SIGKILL.
terminationGracePeriodSeconds: 60
containers:
- name: app
image: my-registry/my-stream-app:latest
ports:
- name: http
containerPort: 8080
env:
# Stable per-pod identity → stoatflow.ha.pod-id
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: STOATFLOW__HA__MODE
value: "active-standby"
- name: STOATFLOW__HA__DESIRED_STANDBYS
value: "2" # replicas - 1 (omit for the default pair)
- name: STOATFLOW__HA__MAX_STANDBYS
value: "3"
readinessProbe: # gates the rolling update
httpGet: { path: /health/ready, port: http }
periodSeconds: 5
failureThreshold: 3
livenessProbe: # stays UP while a standby catches up
httpGet: { path: /health/live, port: http }
periodSeconds: 10
failureThreshold: 6
volumeMounts:
- name: state
mountPath: /var/lib/stoatflow/state
volumeClaimTemplates:
- metadata:
name: state
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 100Gi # ≥ active state size; the standby needs the same capacity
replicas: 2 or more is valid only with ha.mode: active-standby. Multiple instances of the same application with mode: off will corrupt state — see One active instance. The standbys are passive precisely so the cluster can run more than one replica safely.How readiness gates a rolling deploy
The split health probes are what make the rolling update safe and order-independent.
| Role | /health/ready | /health/live |
|---|---|---|
| Active | UP while it is processing | UP while running |
| Standby | UP only once it has caught up | UP while it is still catching up (not killed) |
Because the standby reports ready only after it has caught up, Kubernetes won't move on to the next pod in the roll until the one it just restarted is a caught-up standby. And because a still-catching-up standby keeps its liveness probe UP, Kubernetes won't kill it mid-catch-up. The result is a safe rolling deploy with no custom controller — whichever pod rolls first, the cluster never cuts over to an instance that isn't ready. See Probes and Health checks.
The redundancy floor. With more than one standby, readiness is gated on redundancy as well as catch-up: a caught-up standby reports ready to roll only if rolling it would still leave the active plus at least one other caught-up standby (governed by desired-standbys). So a rolling deploy takes one pod down at a time, standbys first and the active last, and never drops below an active and a warm spare while it runs. A two-replica pair has no other spare to preserve, so this reduces to the pair's behaviour.
Operating the cluster
When HA is enabled, each pod exposes a small set of HTTP endpoints (in-cluster). They are not registered when ha.mode: off.
| Endpoint | Method | Purpose |
|---|---|---|
/ha/status | GET | This pod's view of the cluster: its own role, total committed-changelog lag, the freshness of its coordination view (tailFreshnessMs / tailCaughtUp), the caught-up-standby count, the current promotion-token epoch/holder, and the peers it observes. |
/ha/switch | POST | Swap roles — whichever pod is active drains and the peer promotes. Rejected 409 unless a caught-up (READY_STANDBY) peer exists, or ?force=true. |
/ha/promote?pod=<id> | POST | Ask a specific standby pod to promote. Rejected 409 unless the target is caught up, or ?force=true. |
/ha/demote?pod=<id> | POST | Ask a specific active pod to demote. Rejected 409 unless a caught-up peer can take over, or ?force=true. |
curl -s localhost:8080/ha/status
{
"selfPodId": "my-stream-app-0",
"selfRole": "ACTIVE",
"selfState": "ACTIVE",
"replicationLagRecords": 0,
"replicationLagMs": 0,
"tailFreshnessMs": 180,
"tailCaughtUp": true,
"restartRequired": null,
"readyStandbyCount": 2,
"desiredStandbys": 2,
"redundancyBelowDesired": false,
"tokenEpoch": 1,
"tokenHolder": "my-stream-app-0",
"peers": [
{
"podId": "my-stream-app-1",
"role": "STANDBY",
"state": "READY_STANDBY",
"replicationLagRecords": 1240,
"replicationLagMs": 85,
"failoverPriority": 0,
"generation": 7,
"freshnessMs": 420
},
{
"podId": "my-stream-app-2",
"role": "STANDBY",
"state": "READY_STANDBY",
"replicationLagRecords": 980,
"replicationLagMs": 70,
"failoverPriority": 0,
"generation": 7,
"freshnessMs": 310
}
]
}
The write endpoints are thin shims: they publish a command and return 202 Accepted with the command's offset as an idempotency token; the targeted pod observes it and acts. promote and demote require a ?pod=<id> target; switch targets whichever pod is currently active.
To prevent a self-inflicted outage, switch/promote/demote are rejected with 409 Conflict unless a caught-up (READY_STANDBY) target exists. Override with ?force=true when you deliberately want to hand off to a behind pod — a forced promotion still fully restores to the committed changelog before processing (correctness is never traded; force only skips the readiness check).
curl -X POST localhost:8080/ha/switch
# { "command": "SWITCH", "target": null, "force": false, "commandOffset": 42 }
curl -X POST 'localhost:8080/ha/promote?pod=my-stream-app-1&force=true'
# { "command": "PROMOTE", "target": "my-stream-app-1", "force": true, "commandOffset": 43 }
tailFreshnessMs / tailCaughtUp report how current this pod's view of the coordination topic is. A pod whose tail has gone stale (tailCaughtUp: false) defers failover decisions until its view is current again — correctness is unaffected (the fence is the split-brain backstop), but a persistently stale tail on a pod is worth investigating.
Every role change is recorded on the coordination topic, so its full history can be replayed with a console consumer if you need an audit trail. See REST API for the complete endpoint reference.
Metrics and what to alert on
The runtime exposes HA metrics on /metrics (Prometheus) when HA is enabled.
| Metric | Meaning | Alert on |
|---|---|---|
stoatflow.ha.role | 1 when this pod is the active, 0 when standby. | — (use for dashboards / "is there exactly one active?"). |
stoatflow.ha.standby.replication.lag.records | Gauge — true total committed-changelog lag in records, summed across all changelog partitions and read directly from the standby applier consumer's position; no admin round-trip (0 when active or caught up). | Sustained growth, or staying above acceptable-recovery-lag. |
stoatflow.ha.standby.replication.applied.records | Counter — cumulative committed changelog deltas the streaming standby applier has applied; rate(...) gives apply throughput (resets on promotion/restart). | — (dashboards). |
stoatflow.ha.standby.replication.lag.ms | Gauge — standby replication lag in milliseconds. | Sustained values above your replication-lag SLO. |
stoatflow.ha.peer.freshness.ms | Time since the freshest peer's last heartbeat. | Approaches staleness-threshold-ms. |
stoatflow.ha.peers | Number of observed peers. | Drops below desired-standbys (lost redundancy). |
stoatflow.ha.redundancy.ready.standbys | Gauge — count of caught-up (READY_STANDBY) standbys, including self. | Below desired-standbys. |
stoatflow.ha.redundancy.below.desired | 1 when the active has fewer ready standby spares than desired-standbys — a single point of failure. | Equals 1. |
stoatflow.ha.token.epoch | Gauge — current promotion-token epoch. Increments once per election. | Climbing rapidly (repeated elections / flapping). |
stoatflow.ha.election.outcome | Counter, tagged outcome=won|reclaimed|stood_down_fresh|stood_down_lost — cumulative election outcomes. | Frequent stood_down_lost (contended elections). |
stoatflow.ha.election.latency.ms | Gauge — last claim→ACTIVE promotion latency (-1 until the first). | Above your failover SLO. |
stoatflow.ha.restart.required | 1 when this pod needs a restart (for example, after the source topic's partition count changes). | Equals 1. |
A healthy cluster shows exactly one pod with role = 1, its standbys' replication lag hovering near zero, peers = N-1, and redundancy.below.desired = 0. See Metrics and Observability for the full surface.
Failover characteristics
- Graceful (rolling deploy or
/ha/switch): about one handoff — the active drains and commits, an elected standby promotes, and the old active rebuilds as a standby in place (no pod restart). No reprocessing under exactly-once. - Ungraceful (the active crashes): bounded by detection (
staleness-misses × heartbeat-ms) plus a near-instant promotion, since the elected standby is already warm. This is independent of state size — the cold-restore window is exactly what hot standby removes.
Segmented window/session stores run with the WAL off and would otherwise be durable only on a clean close(). A periodic checkpoint bounds how much of the changelog a pod re-applies after an ungraceful restart — and how much a promoting standby drains — to at most one checkpoint interval. Tune it with stoatflow.state.segment-checkpoint-records (default 100000) and stoatflow.state.segment-checkpoint-ms (default 30000), whichever fires first; see the configuration reference.
For measured cold-start numbers on the default (fast-restart) tier — the cost you avoid with hot standby — see Benchmarks.
Limitations and what to consider
- It's redundancy, not scale. Hot standby keeps warm spares; it does not add throughput. There is still exactly one active instance. To go faster, scale the active vertically.
- More standbys, more footprint and more changelog reads. Each standby is another always-on instance with its own persistent volume, and another consumer streaming the changelog — so
Kstandbys multiply that read fan-outK-fold.max-standbysbounds it; pick the redundancy you need, not the maximum. The standbys run under the same application licence — they don't add seats. - The standbys are not queryable. While passive a standby only mirrors the changelog into local state; interactive queries (
readOnlyStore, the state HTTP endpoints) are served by the active alone. A standby answers reads only once it has promoted. - Give the active CPU headroom. A busy active under a fan-out workload needs its cores; standbys co-located with the active on one node compete for them, and a starved active can miss a commit window (handled cleanly, but it's still a restart). Spread the pods across nodes, or size the node for the whole cluster.
- An extra internal topic. The coordination topic is auto-created and compacted; account for it when planning topics and ACLs. See Coordination topic for the principal's permissions, the single-partition and exclusive-per-application requirements, and the pre-create spec for locked-down clusters.
- At-least-once is bounded-duplicate, not split-brain-proof — see Processing guarantees under failover.
- Increasing a source topic's partition count requires a restart of the cluster; StoatFlow surfaces this via
stoatflow.ha.restart.requiredand the liveness probe, so Kubernetes recycles the pod. - Single region. Every pod must reach the same Kafka cluster; hot standby is not a multi-region or active-active mechanism.
- The default is often the right answer. If a restart-window gap is tolerable, fast restart is simpler and cheaper.
Next steps
- Kubernetes — the full single-replica manifest this page extends.
- Probes — liveness versus readiness and how the readiness gate works.
- Observability — scraping metrics and what to alert on.
- Exactly-once — the guarantee that makes failover split-brain-proof.
Running on Kubernetes
Deploy a StoatFlow application as a single-replica StatefulSet — persistent state volume, termination grace aligned with shutdown, secure license injection, and a representative manifest.
Liveness and readiness probes
Wire StoatFlow's /health/live and /health/ready endpoints to Kubernetes probes — what readiness gates, why a frozen commit pipeline fails liveness, and how to tune probe periods.