OpexFlow can run as an active-standby pair. Both nodes start from the same config; a lease in a shared store decides which one is active. Only the active node runs the processing stack (adapters, NATS ingest, schedule driver, cron scheduler, backfill). The other node is a standby: it serves the API and the UI read-only and watches the lease. If the active node dies, the standby takes over within the lease TTL.
HA is opt-in. Without an ha: section (or with enabled: false) a node is always
active and nothing below applies: no lease, no middleware, no overhead.
How it works
- Each node starts the API, the UI and the health endpoints, then competes for the lease.
- The winner starts the full stack in-process and becomes active. The loser stays
standby and retries every
renew_secs. - The active node renews the lease every
renew_secs. A crashed or partitioned leader stops renewing, the lease expires afterlease_ttl_secs, and the standby acquires it and starts its own stack (it first re-readsflow.json, the event catalogue and the printer and runtime-source files from its data directory). - Fencing. A leader that has not confirmed a renewal for
lease_ttl_secs - margin(margin = a quarter of the TTL, on a monotonic clock) demotes itself before the lease can expire for anybody else. A node that was frozen (VM pause,SIGSTOP) demotes the moment it wakes up and finds its deadline passed, without asking the store. - Demotion = exit. A demoted node stops polling, drains in-flight work like a normal shutdown, and exits non-zero. systemd or Docker restarts it and it comes back as a standby. In-process re-demotion (stopping and restarting all adapters live) is deliberately not done: a restart is the simplest way to be sure no task of the old role survives.
A graceful stop (SIGTERM, docker stop) behaves differently: the active node drains, then
releases the lease, so the standby takes over immediately instead of waiting for the TTL.
Configuration
ha: is a top-level section, a sibling of nats: and storage::
ha:
enabled: true
node_id: "plant-a-1" # default: the host name. MUST differ between the two nodes.
backend: auto # auto | nats | postgres
lease_ttl_secs: 15
renew_secs: 5 # default ttl / 3
bucket: "opex_flow_ha" # NATS KV bucket
key: "leader" # NATS KV key
postgres_lock_key: 5715144172572004183 # Postgres advisory-lock key| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Turn HA on. |
node_id |
host name | Shown in /api/status and on the other node. Must be unique per node. |
backend |
auto |
auto uses NATS KV when the nats: section is enabled, otherwise a Postgres advisory lock when postgres: is enabled. Neither available: config error. |
lease_ttl_secs |
15 |
A dead leader is replaced after at most this long. At least 2. |
renew_secs |
ttl / 3 |
Renew / acquire interval. Must be smaller than lease_ttl_secs and below 3/4 of it (the self-fencing point). |
bucket, key |
opex_flow_ha, leader |
NATS KV names. |
postgres_lock_key |
5715144172572004183 |
One cluster = one key; use different keys for different pairs sharing a database. |
opex-flow --check-config rejects an enabled ha: with no usable lease store, a TTL below 2 s,
or a renewal interval that cannot keep the lease.
NATS KV backend
The lease is one key in a JetStream KV bucket whose max_age equals lease_ttl_secs, so the
key of a dead leader disappears by itself. Acquiring is an atomic create; renewing is an
update checked against the revision the node last wrote, and losing that check means
leadership is lost. The bucket is created on first use. If it already exists its max_age
must equal lease_ttl_secs (both nodes must use the same TTL); otherwise the node reports the
mismatch and stays standby. For a NATS cluster pre-create the bucket with 3 replicas:
nats kv add opex_flow_ha --history 1 --ttl 15s --replicas 3. The NATS server may expire the key
slightly after the TTL, so budget lease_ttl_secs plus a second or two for a takeover.
The HA lease uses the connection settings of the nats: section (url, creds_file) but a
connection of its own. The section must be enabled: true (and, as always, list at least one
device).
Postgres backend
The active node holds a session-level advisory lock (pg_try_advisory_lock) on a dedicated
connection; the server releases it when that session ends, for example when the process dies. The
active node checks the session with SELECT 1 on every renewal. The standby retries every
renew_secs and learns the leader’s name from the lock holder’s application_name
(opex-flow-ha:<node_id>, visible when the database user may read pg_stat_activity).
A process crash releases the lock at once (the socket closes). A network partition
does not: the server keeps the dead session until it notices, so lower the server’s
tcp_keepalives_idle / tcp_keepalives_interval / tcp_keepalives_count (or set tcp_user_timeout)
to the failover time you want. The old leader still fences itself at 3/4 of the TTL.
What a standby does
| Standby | |
|---|---|
| API / UI | Up. GET routes work (flow, config, status, history, …), so the UI shows a read-only view. A banner shows Standby — read-only (leader: <node>). |
/api/hooks/*, /api/ingest/* |
503 + Retry-After: a load balancer or the sender moves to the active node. |
Writes (flow deploy, config reload, sources, printers, lookups, events, dead-letter replay, license, push dead letters, API tokens and users, /mcp) |
503 {"error":"standby","leader":"<node>"} + Retry-After. |
Allowed POSTs |
Sign-out and OIDC sign-in, /api/script/* (validate / generate), /api/flow/validate, /api/schedules/preview, /api/obs/test. They change no state. |
| Adapters, NATS ingest, schedule and cron drivers, backfill | Not started. |
/api/health |
200: liveness must not restart a healthy standby. |
/api/ready |
503 {"ready":false,"role":"standby"}. |
The active node answers /api/ready with 200 {"ready":true,"role":"active"} once its stack is
up. While a node is promoted but still starting, it answers 503 with "role":"promoting".
GET /api/status has an ha object with the full detail:
{
"ha": {
"enabled": true, "role": "active", "node_id": "plant-a-1", "leader": "plant-a-1",
"backend": "nats", "serving": true, "since_ms": 1767225600000,
"last_renew_ms": 1767225603000, "takeovers": 1, "demotions": 0,
"lease_ttl_secs": 15, "renew_secs": 5, "last_error": null
}
}takeovers counts the times this node became active after seeing another node lead;
demotions counts lost or unrenewed leases. Without HA the object is {"enabled":false}.
Alerts and metrics
A takeover raises a warning alert and a demotion a critical one through the configured
alert channels (telemetry.alerts), and both are logged. With the telemetry
feature, /metrics exposes opex_connector_ha{stat="active"} (1 on the active node) and
opex_connector_ha{stat="takeovers"}. Alert on “no node reports active” and on a rising
takeovers.
What moves between nodes, and what does not
The standby starts its stack only on promotion, so it reads whatever is in its own data directory at that moment. OpexFlow does not copy state between nodes; you decide how it gets there.
| State | Where | On takeover |
|---|---|---|
config.yaml |
Same file on both nodes (git, config management, a shared read-only volume). | Read at process start. A config change needs a restart of the standby. |
flow.json |
data_dir |
Re-read and recompiled on promotion. |
events/*.json (user event types) |
data_dir |
Re-read on promotion. |
printers.runtime.json |
data_dir |
Re-read on promotion (printer registry rebuilt). |
sources.runtime.json |
data_dir |
Synced on promotion: sources added or removed through the UI since the standby booted are added / removed. |
lookups.db |
data_dir |
Opened at start and read live; a file replaced by rsync needs a standby restart. Use a shared volume, or restart the standby after a sync. |
push_queue.db, data.db, dedupe.db, reconcile.db, pipeline_traces.db, events.db, printer_jobs.db, auth.db, audit.db |
data_dir |
Node-local, not replicated. |
Practical consequences:
- Edit flows, printers, sources and event types on the active node (the standby refuses
writes). Replicate
flow.json,events/,printers.runtime.json,sources.runtime.json(andlookups.db) to the other node’sdata_dirwithrsyncon a timer,syncthing,lsyncd, or a shared volume. A node that has never received aflow.jsonseeds one from its config, as on a fresh install. - Single writer. Never point two running nodes at the same SQLite files. Both processes
open
push_queue.dband drain it, so a shared file would be delivered twice, and SQLite’s WAL mode is not safe over network filesystems. Keep every*.dblocal to its node. push_queue.dbof a node that died is drained by that node after it restarts, not by the new active node. Records already in the dead node’s queue are delayed, not lost, as long as its disk survives.- Sessions and named API tokens are node-local (
auth.db). The legacyapi_tokenand the OIDC provider work on both nodes; create named tokens on the node that will use them, or configure the sameconnector.api_tokenon both. - A license seat is per device: activate the key on both nodes.
No message lost
HA moves processing between nodes; it does not make an unreliable source reliable. What survives a failover depends on how the data reaches OpexFlow:
| Source | Behaviour on failover |
|---|---|
| NATS JetStream ingest | No loss. Unacknowledged messages are redelivered to the new active node (same durable consumers). Records are handled at least once: use a dedupe node or idempotent receivers for exactly-once effects. |
Webhooks and POST /api/ingest |
The standby answers 503 + Retry-After; a sender that retries (DOKU, a load balancer with retries, your ERP) delivers to the new active node. A sender that does not retry loses what it sent during the gap. |
| Polled devices (Modbus, OPC-UA, HTTP, MTConnect, …) | Polling resumes on the new node. Values that were not polled during the gap (about the TTL) are not recoverable unless the device or historian buffers them. |
| Records already inside the active node (queue, executor lanes) | Held in push_queue.db of that node: delivered after it restarts (see above). A graceful stop drains first. |
For no-loss failover of machine data, feed OpexFlow through NATS JetStream (devices publish to the broker, the broker holds the stream while no node is active) and/or use retrying senders.
Deployment pattern
Two nodes, one shared NATS (clustered for production), a load balancer in front, and a
health check on /api/ready:
senders / UI ──► load balancer ──► node-a :3000 (active, ready 200)
health: GET /api/ready └► node-b :3000 (standby, ready 503)
▲
node-a, node-b ──── NATS JetStream (lease bucket + ingest streams)The load balancer sends traffic only to the node that answers 200 on /api/ready. Keep
/api/health for the container or process liveness check: it stays 200 on a standby.
docker-compose (two nodes, one NATS)
services:
nats:
image: nats:2.10
command: ["-c", "/etc/nats/nats-server.conf"]
volumes:
- ./nats-server.conf:/etc/nats/nats-server.conf:ro
- nats-data:/data
flow-a: &flow
image: opex-flow
restart: always # a demoted node exits non-zero and must come back as standby
stop_grace_period: 45s # the active node drains, then releases the lease
depends_on: [nats]
env_file: .env
environment:
OPEX_BIND_ADDR: 0.0.0.0
command: ["/etc/opex-flow/config.yaml"]
volumes:
- ./config-a.yaml:/etc/opex-flow/config.yaml:ro # same as config-b.yaml but ha.node_id
- data-a:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"]
interval: 10s
ports: ["3001:3000"]
flow-b:
<<: *flow
volumes:
- ./config-b.yaml:/etc/opex-flow/config.yaml:ro
- data-b:/data
ports: ["3002:3000"]
volumes: { nats-data: {}, data-a: {}, data-b: {} }The image’s built-in HEALTHCHECK probes /api/ready, which is 503 on a healthy standby, so
the compose file overrides it with /api/health. Both configs carry the same sections plus a different ha.node_id (flow-a, flow-b). The load
balancer (nginx, HAProxy, a cloud LB) health-checks flow-a:3000/api/ready and
flow-b:3000/api/ready.
systemd (two hosts)
Use the unit from Deployment. Two changes for HA:
[Service]
Restart=always # a demoted node exits non-zero and must restart as a standby
RestartSec=5
StartLimitIntervalSec=0 # flapping leadership must not exhaust the start limitRun rsync -a --delete-after active:/var/lib/opex-flow/{flow.json,events,printers.runtime.json,sources.runtime.json} standby:/var/lib/opex-flow/
(or a shared volume) on a timer so the standby has the latest flow when it is promoted.
Failover timing
| Event | Takeover time |
|---|---|
| Active process crashes (NATS) | at most lease_ttl_secs + NATS expiry slack, then the stack start time (adapters connect) |
| Active process crashes (Postgres) | the next standby try after the socket closes: at most renew_secs |
| Active host or network dies | NATS: lease_ttl_secs; Postgres: server keepalive timeout |
docker stop / SIGTERM on the active node |
immediate after its drain: the lease is released |
| Lease store (NATS / Postgres) unreachable | the leader keeps working until ttl - margin without a confirmed renewal, then demotes and exits. The lease store is a dependency of the active node: run it highly available too |
Overlap guarantee: the leader stops acting at 3/4 of the TTL after its last confirmed renewal (measured on a monotonic clock from the start of that request), so, barring a clock or scheduler stall longer than the margin, two nodes are never active together. Work already accepted by a demoted node finishes during its drain (at most 30 s), so downstream effects are at-least-once, never silently dropped.
Limits
- A pair, not a cluster: one active node, one standby.
- Both nodes need access to every source and sink (same network reach).
- State replication is file-level and yours to provide (see above); there is no live replication of the SQLite databases.
- The lease is only as available as its store. Two nodes that both lose the store both demote.