Home › Embedded, industrial & energy › Safety-critical design
algorithm medium

Hot-standby redundancy

Problem A controller whose stop or restart is unacceptable (PLCs, gateways, servers in a control loop) needs a second unit that takes over seamlessly after a crash, while never letting two units drive the outputs.

A second, always-current controller shadows the primary and takes over within a few cycles of silence; epochs stop the old primary from fighting back.

O(1) per cycle (one small heartbeat plus state checkpoint); failover time = detection timeout (miss_limit cycles) plus takeover action
redundancyfailoveravailabilitysafetycontrol
Overview

One controller runs the plant; a second runs the same program on the same inputs and only listens. If the primary's heartbeat stops for a few cycles, the backup drives the outputs from state it already holds: a small blip, not a restart. The hard part is a primary that is cut off, not dead, and keeps writing. An epoch bumped on every takeover, plus an output gate refusing older epochs, leaves exactly one authority.

Key facts

Use when

  • Downtime or a restart transient is unacceptable: PLCs, DCS controllers, gateways, servers in a fixed-rate loop.
  • Industrial automation: OPC UA defines transparent, cold, warm, hot and hot-and-mirrored server redundancy (Part 4 §6.6.2).
  • Networking: VRRP elects one router to forward for a virtual address and fails over when it becomes unavailable (RFC 5798).
  • IT services and databases: Pacemaker detects host and application failures and recovers them, with promoted/unpromoted roles (clusterlabs.org).
  • The fault model is crash or hang of one node (fail-silent), not wrong answers; for those add voting.

Avoid when

  • Faults can produce plausible wrong outputs: a standby happily takes over from a controller that was already wrong. Use voting or a safety monitor.
  • A restart plus a safe state is cheap and tolerable; one watchdog is then enough.
  • You need more than one failure tolerated or a strongly consistent replicated log: use Raft.

Deep dive

How it works

Parts:

  • primary node — runs the loop, drives the outputs, sends one heartbeat with state per cycle
  • backup node — runs the same loop on the same inputs, adopts each checkpoint, counts silent cycles
  • sync link — carries the heartbeat (ideally two independent paths)
  • epoch — counter bumped on every takeover; higher epoch wins
  • fenced actuator — remembers the highest epoch seen, drops outputs from lower ones

Operations: replicate, heartbeat, detect, take over, fence, resolve duplicates, rejoin (steps below).

  1. Replicate execution. Both nodes read the same inputs (wired to both, or mirrored over a sync link) and run the same fixed-rate loop. Only the primary's outputs reach the plant.
  2. Heartbeat + checkpoint. Each cycle the primary sends {id, epoch, state}. The backup overwrites its state with it. This corrects any divergence (different rounding, missed cycle, a restarted node) and doubles as the liveness signal.
  3. Detect. The backup counts consecutive cycles without a valid heartbeat; at miss_limit it promotes itself. The timeout must exceed worst-case link jitter plus the peer's longest legal pause, or failovers become spurious.
  4. Take over with a higher epoch. epoch += 1. The backup already holds current state, so the first output continues the previous one (bumpless transfer) instead of restarting from initial conditions.
  5. Fence. The actuator side (output module, valve controller, storage) remembers the highest epoch seen and drops outputs from lower ones. This is the same idea as fencing tokens for a lease.
  6. Resolve duplicates. A node that sees a heartbeat with a higher epoch demotes itself and resyncs. On equal epochs (both promoted at once) the lower id wins.
  7. Rejoin. A repaired node starts as backup, adopts the next checkpoint, and only then counts as redundancy again. Redundancy is lost until that happens, so alarm on it.
cycle:    ... 49    50   51   52   53
primary:  out  out  X (dead)
backup:   shadow  s=0  s=1  s=2  s=3 -> promote, epoch 1, out (continues the integrator)
actuator:           holds last value     accepts epoch 1; epoch 0 now rejected

Code

Std only; one node, one fenced actuator. Tested by a lockstep harness (below): crash of the primary at cycle 50 gives first backup output at cycle 53, equal to the ideal integral (bumpless); a restarted node resyncs and ends with identical state; a link cut with both nodes alive ends with exactly one primary and the stale primary's outputs rejected.

#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Role { Primary, Backup }

/// Sent by the primary once per cycle: liveness proof and state checkpoint in one message.
#[derive(Clone, Copy, Debug)]
pub struct Heartbeat { pub id: u8, pub epoch: u32, pub state: i64 }

#[derive(Clone, Copy, Debug)]
pub struct Output { pub epoch: u32, pub value: i64 }

pub struct Node {
    pub id: u8,
    pub role: Role,
    pub epoch: u32,
    pub state: i64, // controller state, e.g. an integrator
    silent: u32,    // cycles since the last valid heartbeat
    miss_limit: u32,
}

impl Node {
    pub fn new(id: u8, role: Role, miss_limit: u32) -> Self {
        Node { id, role, epoch: 0, state: 0, silent: 0, miss_limit }
    }

    /// One control cycle. Both nodes read the same input and run the controller;
    /// only the primary's output reaches the plant. `rx` is the heartbeat that
    /// arrived since the last cycle (it carries the sender's state after cycle n-1).
    pub fn tick(&mut self, input: i64, rx: Option<Heartbeat>) -> (Option<Heartbeat>, Option<Output>) {
        let mut valid = false;
        if let Some(hb) = rx {
            let newer = hb.epoch > self.epoch;
            // same epoch: the primary wins over a backup; two primaries: lower id wins
            let tie = hb.epoch == self.epoch
                && (self.role == Role::Backup || hb.id < self.id);
            if newer || tie {
                self.epoch = hb.epoch;
                self.role = Role::Backup; // demote if we were primary
                self.state = hb.state;    // resync to the checkpoint
                valid = true;
            }
        }
        self.state += input; // controller step (integrator)
        if self.role == Role::Backup {
            self.silent = if valid { 0 } else { self.silent + 1 };
            if self.silent >= self.miss_limit {
                self.role = Role::Primary; // takeover: new epoch fences the old primary
                self.epoch += 1;
                self.silent = 0;
            }
        }
        if self.role == Role::Primary {
            let hb = Heartbeat { id: self.id, epoch: self.epoch, state: self.state };
            (Some(hb), Some(Output { epoch: self.epoch, value: self.state }))
        } else {
            (None, None)
        }
    }
}

/// The plant-side gate: outputs from an older epoch are ignored (fencing).
#[derive(Default)]
pub struct Actuator { max_epoch: u32, pub rejected: u32, pub last: Option<Output> }

impl Actuator {
    pub fn drive(&mut self, o: Output) -> bool {
        if o.epoch < self.max_epoch { self.rejected += 1; return false; }
        self.max_epoch = o.epoch;
        self.last = Some(o);
        true
    }
}

Complexity and constants

  • Per cycle: one send (primary), one receive and compare (backup), O(state) copy for the checkpoint. Heartbeat above is 13 bytes packed (1 + 4 + 8), 16 in memory.
  • Detection: miss_limit * period. VRRP's rule is 3 * advert_interval + skew with skew = (256 - priority) * advert_interval / 256; with the defaults (1 s, priority 100) that is about 3.6 s.
  • State size dominates: a 1 kHz loop with a 200-byte checkpoint is 200 kB/s (1.6 Mbit/s), trivial on Ethernet, impossible on a 9.6 kbit/s serial link.
  • The plant sees miss_limit missing outputs (3 in the test); the actuator's hold-last-value covers the gap.

Efficiency

  • Cost: per cycle one heartbeat send, one receive-and-compare and an O(state) copy; the heartbeat above is 13 bytes packed (16 in memory). A 1 kHz loop with a 200-byte checkpoint is 200 kB/s: trivial on Ethernet, impossible on 9.6 kbit/s serial. Failover time is miss_limit * period; VRRP's rule is 3 * advert_interval + skew, about 3.6 s at defaults (RFC 5798). A hot standby burns the same CPU as the primary. Bound: detection latency and link bandwidth, not compute.
  • Levers:
    • send state, not history — checkpoint of integrators and timers is small; input replay costs more — compact state
    • delta checkpoints — changed fields each cycle, full state every N — large state
    • heartbeat piggybacked on the checkpoint — one packet per cycle, no extra syscalls — always
    • batch loops' checkpoints per frame — fewer packets — many loops per node
    • warm or cold standby — saves the second CPU and link, costs a restart transient — tolerant loops
  • Naive → standard → state of the art:
    • restart on fault, watchdog only · downtime of a full boot plus initialisation
    • heartbeat with periodic checkpoint, hot standby · about miss_limit cycles
    • dual paths plus actuator fencing · same cycles, no split brain

Implementation notes

  • Give the heartbeat a monotonic sequence number as well, so a delayed old packet cannot reset the miss counter or overwrite newer state.
  • Use two independent sync paths (dedicated cable plus the plant network) and count a peer as alive if either delivers; this removes most spurious takeovers.
  • Fencing must live at the consumer: output cards, drives, or the shared disk. Where hardware cannot check an epoch, cut the old primary off by force (power switch, reset line, STONITH in Pacemaker clusters).
  • Input mirroring must be deterministic: both nodes need the same inputs for the same cycle, or the shadow diverges and every checkpoint becomes a small jump on takeover.
  • Start the watchdog on both nodes; a hung primary that stops heartbeating is then also reset, and a repaired node rejoins as backup.
  • Expose role and epoch as a diagnostic tag: a plant silently running on its backup has no redundancy left.

Pitfalls

  • Split brain: both nodes drive outputs after a link failure. Without an actuator-side epoch check or forced shutdown of the other node, you get two writers.
  • Takeover from a bad primary: a standby copies a corrupted checkpoint just as faithfully. Validate state (CRC, range checks) before adopting it.
  • Preemption flap: a recovered high-priority node that retakes the role immediately causes a second blip. VRRP makes preemption configurable; for plants, prefer manual switch-back.
  • Timeout inside the longest legal pause: a flash erase or a GC pause longer than miss_limit * period causes spurious failover.
  • Untested failover: the standby path runs rarely, so its rot is invisible. Force a switch-over on a schedule.
  • Common cause: same software, power supply or rack defeats redundancy.

Variants and alternatives

Cold / warm / hotOPC UA names these failover modes for non-transparent server redundancy, as well as "hot and mirrored" (synchronised sessions); the cost grows with how little the client must redo.
Active-active / voting (2oo3)majority voting masks a wrong answer without any failover time, at triple hardware cost.
Quorum replication (Raft)survives f failures with 2f+1 nodes and a replicated log, but needs a majority and gives no sub-cycle switch-over.
VRRP / virtual IPnetwork-level hot standby: the backup claims a shared address when advertisements stop.
Parallel network paths (PRP/HSR, IEC 62439-3)duplicate the link instead of the node: frames travel both paths, no failover delay.

In production

  • Computing: process pairs in Tandem NonStop systems (checkpointing primary to backup); Pacemaker/Corosync clusters for databases and services.
  • Industrial: redundant PLC pairs such as Siemens S7-400H / S7-1500R/H and Rockwell ControlLogix redundancy (see PLC scan cycle and OPC UA server redundancy).
  • Networking: VRRP routers (RFC 5798), implemented by keepalived.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
keepalivedCacassen/keepalivedapt install keepalived————VRRP (RFC 5798) virtual IP failover, health-check scripts, priority and preemption
PacemakerCClusterLabs/pacemakerapt install pacemaker————cluster resource manager: promote/demote, constraints, STONITH fencing
CorosyncCcorosync/corosyncapt install corosync————membership and messaging layer under Pacemaker; heartbeat over redundant links
fence-agentsPythonClusterLabs/fence-agentsapt install fence-agents————power/reset agents used to force the old primary off

Sources

  • Jim Gray, Why Do Computers Stop and What Can Be Done About It?, Tandem TR 85.7, 1985 (process pairs). PDF
  • N. Budhiraja, K. Marzullo, F. B. Schneider, S. Toueg, The Primary-Backup Approach, in Distributed Systems, 2nd ed., ed. S. Mullender, ACM Press/Addison-Wesley, 1993.
  • P. A. Alsberg, J. D. Day, A Principle for Resilient Sharing of Distributed Resources, ICSE 1976.
  • S. Nadas (ed.), Virtual Router Redundancy Protocol (VRRP) Version 3, RFC 5798, IETF 2010. RFC
  • OPC Foundation, OPC UA Part 4: Services, 6.6.2 Server Redundancy (transparent, cold, warm, hot, hot-and-mirrored). spec
  • ClusterLabs, Pacemaker documentation (fencing/STONITH). docs

Builds on

Used by

ups power distribution · plannedUPS, PDUs, busways and redundancy schemes (N+1, 2N) for datacenter power

Systems (24):

Alternatives and related

redundancy voting · plannedN-modular redundancy and MooN voting (2oo3, TMR) with discrepancy detection
Fixed-rate control loopA sampled controller (motor loop, balancing, filters) on a microcontroller or real-time OS silently changes behaviour when its loop timing drifts or jitters. This keeps the loop running at a steady rate.
fault detection isolation recovery · plannedMonitor/response tables, fault isolation and safe-mode recovery hierarchies for autonomous systems
safety monitor architecture · plannedIndependent doer/checker safety monitor that forces a safe state when the main channel misbehaves
LeasesA lock held by a crashed node blocks everyone forever. In leader election, shard ownership or cached reads, this lets ownership expire safely so another node can take over without hearing from the old holder.Fencing tokensA node that held a lock can freeze, lose the lock to another node, wake up and still write. Anyone who needs one writer at a time to shared state needs the state itself to refuse the old writer.Phi accrual failure detectorCluster members must decide when a node has failed from missed heartbeats, but a fixed timeout is too short on jittery networks and too slow on clean ones. This adapts failure detection to observed network behaviour.
plc scan cycle · plannedCyclic read-inputs, execute-logic, write-outputs execution with IEC 61131-3 programs
opc ua · plannedOPC UA industrial protocol: address space, subscriptions and monitored items, security modes