Skip to content

Architecture

Everything in jaque, from the dashboard to Livestatus to the notifier, is a fold over the same append-only event log. Nothing downstream of the state machine reaches in and mutates a status field directly — it reads events from the log at its own pace and builds whatever view it needs from them.

checks (tcp, http, dns, icmp, tls, snmp, legacy, wasm, command)
|
v
scheduler --> executors --> state machine (pure fold)
|
v
event log (memory | pebble | nats)
|
+---------------+---------+---------+-------------+
| | | | |
v v v v v
projections notifier perfdata archive (replay ->
(UI, API, (adapters) sinks (segments) rebuild any
livestatus) (clickhouse, projection)
remote_write, http,
exec, file, otlp)

sched/ decides when the next check for each object runs. Heap is the baseline Scheduler implementation (ADR-004): a min-heap keyed by due time, with an ID→index map so cancelling or rescheduling an entry is O(log n) instead of a scan. To avoid every object on the same interval firing in the same instant, Jitter derives a deterministic offset in [0, interval) from a hash of the object’s ID — not a random draw, so the same config reproduces the same schedule across restarts and replay. A timer wheel is the documented alternative, but only lands if a benchmark shows the heap is the bottleneck.

exec/ runs whatever the scheduler hands it under global and per-host concurrency limits, via a worker Pool. Every check type — native (tcp, http, dns, icmp, tls, snmp), legacy Nagios plugin exec, WASM, and command — implements the same Runner interface and is dispatched through that one pool, so the scheduler and the pool never know which kind of check they’re running.

state/ is where a check result becomes a decision. Transition(state, event, clock) -> (state', effects) is pure: no I/O, no goroutines, no side effects taken directly — only a new state and a list of effects as data for the runtime to interpret. That purity is what lets soft/hard transitions, flap detection and reachability be property-tested against thousands of random event sequences without starting a single goroutine.

eventlog/ is the transport every domain event flows through (ADR-002): AcknowledgementSet, StateChanged, NotificationSent, and everything else land here, never as a direct mutation. The log runs over one of three transports selected with -eventlog: memory (no durability, replay only from process start), file:// (embedded Pebble, durable, no external service), or nats:// (external NATS JetStream, for clustered deployments where more than one process reads the same log). Because nothing mutates state outside the log, every projection is rebuildable by replaying it from the start.

projection/ folds eventlog.Event into the per-object read model: Fold is a pure (Object, Event) -> Object, and Table wraps it behind a concurrency-safe map that implements eventlog.Applier. This is the same shape the dashboard, the query API, and Livestatus each read from — one fold, several consumers at their own read position.

notifier/ and notify/ split evaluation from delivery. notify/ holds the decision logic — contacts, policies, escalations, windows — run by the owning engine, which appends NotificationRequested to the log. notifier/ never decides on the log, only reacts to it: a Consumer folds notification events and calls a dispatcher for whatever it owns and still has open, so a notifier only delivers, it never evaluates.

sink/ gets every check’s perfdata to wherever it’s supposed to end up, via seven types: clickhouse, remote_write, archive, http, exec, file, and otlp. Each is declared as a named entry under sinks: in CUE, typed by type the same way checks and contacts are, and each reads the log independently — a slow sink falls behind without blocking a fast one.

All of the above lives in one binary. -target picks which role this process plays: all (everything, the default), engine (scheduler, executors, state machine), worker (executors only, pulled from a work queue), ui (dashboard and API, no execution), sink, or notifier. Splitting roles across processes is a deployment choice, not a different codebase — the same packages run either way.

The decisions behind this shape, and why each one was made, live in Design decisions, which points at the full ADRs in docs/adr/.