Decision records
ADR-0035: Instance lifecycle, draining, configuration generations and recovery
Status: accepted (design, D0) — 2026-10-07
Lifecycle
Stopped → Starting → Warming → Ready → Active → Draining → Stopped
│ │ │ │ │
└──────────┴────────┴────────┴──────────┴──→ Failed (exit, start timeout,
drain timeout → forced stop)- Starting: process spawned with its instance id, generation and CPU set. It binds its reuseport sockets only when told to activate — so Starting/Warming receive no traffic.
- Warming: configuration prepared, runtimes and caches initialised; readiness probe
answers
{"generation": N, "state": "warming"}. - Ready: readiness passed; still not bound. Active: listeners bound (joins the reuseport group); the kernel starts sending new connections.
- Draining: listening sockets closed first (queued connections migrate,
tcp_migrate_req), keep-alive connections told to close (Connection: close, HTTP/2GOAWAY), in-flight requests finish untildrain_timeout(default 30 s), then graceful shutdown; forced kill only afterdrain_timeout + kill_grace. - Liveness ≠ readiness ≠ saturation: a saturated instance is healthy; restarts happen only on exit or failed liveness, never because of load.
Instances have stable ids (web-0, web-1, …), and the controller records PID, CPU
set, generation, state and timestamps in its state directory
(/run/scalws-controller/instances/<id>.json, 0600, written atomically).
Configuration generations
A generation is a validated, immutable copy of the configuration:
<state>/generations/<n>/scalws.yaml written to a temporary name, fsynced and renamed;
never edited in place. Instances start against an explicit generation path and report it
in readiness. A reload validates first, then rolls instances one by one (start new
generation instance → Ready → Active → drain one old), so capacity never drops below the
current count. Rollback is the same procedure towards the previous generation. An
instance is never started against a partially written file.
Failure recovery
- Instance crash: Failed, restarted with exponential backoff (1 s → 60 s); more than
max_restarts(5) inrestart_window(5 min) → stays Failed, alert, no fork storm. - Controller restart: reads the state directory, verifies each recorded PID (process start time and executable), re-adopts healthy instances and reconciles towards the desired count — it never stops healthy instances to “start clean”.
- Host limits:
max_instances,cpus_per_instance, reserved system/controller and application CPUs,min_free_ram; optional cgroup v2 CPU/memory/pids per instance. If cgroup delegation is unavailable the controller says so in its status and logs (limits unenforced), it does not pretend. - Emergency switch:
autoscaling: off(config orscalws-controller disable) freezes the instance count immediately; lifecycle management continues.
Anti-thrashing
Scale-out window 3–5 s above threshold, scale-in window 60 s below, cooldown after any
action (default 30 s), start and drain timeouts, at most one action in flight, hysteresis
between scale_out_above and scale_in_below.