Binions is an event-driven platform built from small, single-purpose Rust daemons that cooperate over a Redis-Streams event bus. There is no central application server and no orchestrator process sitting in the request path: each daemon reads the events addressed to it, does its one job, and emits new events. The whole system runs on a single Linux host and communicates over localhost. This page explains the moving parts, how one event flows through them end to end, and how work is processed concurrently so the platform stays fast under load.
The core idea. Apps and hardware emit events; small daemons turn events into actions and new events; short YAML playbooks decide which steps run. Every coordination decision is data on the bus, not code in a monolith.
| Style | Event-driven microservices, choreographed (no central orchestrator in the data path) |
| Where it runs | One Linux host — all daemons + their Redis instances talk over localhost |
| Event bus | Redis Streams (consumer groups), one Redis instance per daemon |
| Language | Pure Rust — one Cargo workspace, shared crates + daemon binaries |
| Process model | One daemon = one systemd service = one binary |
| Concurrency | Every daemon dispatches actions on a bounded worker pool; the playbook engine runs many runs in flight at once |
| Delivery guarantee | At-least-once on the bus + idempotency → effectively once-processed |
mailbox (email/MQTT/AMQP), database (multi-backend storage — PostgreSQL, SQLite, MySQL, MongoDB, MSSQL), aiinjector (AI models), datatransporter (object/file storage), dataanaliser (analytics), webhookcaller (HTTP/SOAP), scheduler (cron-style triggers), traefiklinker (reverse-proxy routes), modbus (industrial PLCs), logger (the log sink), cliconsole (operator control plane), playbook (the playbook engine), and showman (HTTP ingress/router).XREADGROUP) and acknowledge (XACK) once an event is handled.run:, parallel:, loop:, and wait_for:. See Playbook anatomy for the full grammar.Every message on the bus is one of four kinds. The kind decides how it is routed and whether it can be refused.
| Kind | Means | Delivery | Routed to |
|---|---|---|---|
| Action | "Please do this" — a request to one owning daemon | The owner may reject it | actions:<domain> |
| Fact | "This happened" — a past-tense state change | Broadcast to any number of consumers; cannot be rejected | events:<service> |
| Log | Telemetry / observability | Consumed by the logger | events:logs |
| Control | A runtime op for a specific daemon (reload config, set log level, drain) | Directed | control:<service> |
The full data structure is documented in Event envelope & taxonomy.
Stream names are derived, not configured — there is no central routing table. The producing or target service name yields the stream:
events:<service> # Facts, e.g. events:database (broadcast)
actions:<domain> # Actions, e.g. actions:database (single owner)
control:<service> # Control, e.g. control:logger-service
events:logs # Logs (one shared stream, consumed by the logger)
A precise detail. A daemon's fact stream uses its full service name minus the
-servicesuffix (database-service→events:database), but its action inbox uses a shorter domain it chooses (database-servicelistens onactions:database;dataanaliser-serviceonactions:analytics;mailbox-serviceonactions:mail). The two shorts are deliberately allowed to differ.
Each daemon also owns a private Redis key namespace, svc:<service>:<category>:<entity>:<id> — for its state (:state:), its outbox (:outbox:pending / :outbox:failed), idempotency claims (:processed:event:<id>), and local projections of other daemons' facts.
This is the exact path every event takes through a daemon, implemented once in the shared framework so every daemon behaves identically:
XREADGROUP … BLOCK on each subscribed stream and decodes the JSON envelope.event_id with SET svc:<s>:processed:event:<id> NX EX (default TTL 7 days). If the claim already exists, the event is a duplicate — it is acknowledged and skipped.(kind, event_type) is looked up and invoked on a strongly-typed payload. Handlers run on a bounded worker pool, so independent events are processed concurrently rather than one at a time. If no handler is registered, the event is acknowledged and skipped ("unhandled").XACK'd. A separate outbox worker later drains the outbox, publishes each event to the right destination stream on the right Redis instance, and only then removes it from the outbox.Why the outbox. Splitting "decide" from "publish" is the transactional-outbox pattern. A daemon never loses an emitted event if it crashes mid-publish: unpublished events stay in
svc:<s>:outbox:pendingand are retried; exhausted ones land insvc:<s>:outbox:failed(a dead-letter stream) for inspection.
The platform is built to keep moving even when individual operations are slow. Two layers cooperate to make that true: the daemon framework and the playbook engine. Both follow the same rule — run independent work in parallel up to a configurable cap, but never reorder operations that touch the same thing.
Every daemon dispatches incoming actions on a bounded worker pool instead of handling them one at a time. A slow operation — a long network scan, a database query, an SSH command — no longer blocks the other actions waiting behind it. The pool size is a configurable cap that protects the host from overload while still allowing many actions to progress at once.
The engine that runs playbooks decouples accepting a trigger from executing the run. That means many playbook runs are in flight at the same time, bounded by configurable concurrency caps — a global cap on total runs and a per-playbook cap — rather than an artificial serial ceiling. A slow run does not hold up unrelated runs; throughput scales with the host's CPU.
Within a single run, the execution mode chooses how steps relate:
run: step waits for its own response fact before the next step starts, and that payload flows forward as ${prev}. Existing playbooks keep working unchanged.run: step fires its action and moves on immediately without waiting. One run can fan out many actions at once and then collect just the results it needs with explicit wait_for: steps — turning what would be many serial waits into one burst of concurrency.Ordering is preserved where it matters. Concurrency never reorders operations on the same resource. Actions that target the same thing — the same schedule name, the same page, the same proxy route, the same PLC — are still serialised in the order they were issued. Only genuinely independent work runs in parallel.
A simple single-step run completes in roughly a tenth of a second, and a steady stream of triggers is handled at tens of runs per second on a modest host, with stable memory use under sustained load. For the mechanics of writing concurrent playbooks, see Playbook patterns.
The daemon framework wires the same operational concerns into every daemon:
| Health | An axum server exposes /health/live, /health/ready, and /metrics on loopback. Readiness is computed from cached atomic timestamps (no Redis call on the request path) so it answers in well under a millisecond. Responses are plain text. |
| Metrics | Prometheus counters/histograms — events processed, processing duration, outbox size, health-probe ages. |
| Tracing | OpenTelemetry (OTLP/gRPC) to Jaeger. A W3C traceparent rides in each envelope's metadata, so a producer→consumer chain renders as one waterfall. |
| Lifecycle | On boot a daemon verifies its licence, signals systemd READY=1, and runs a watchdog keep-alive; on SIGTERM it drains in-flight work before exiting. |
| Runtime config | A hot-reloadable config object can be swapped by Control.* events (for example, change the log level without a restart). |
Single host, by design. The architecture is deliberately one-host: every daemon and every Redis instance lives on the same machine. There is no Kubernetes, no clustering of the bus, and no horizontal scale-out of daemons. Throughput comes from concurrency within the host, not from spreading across machines — which keeps operations simple and latency at
localhostspeed.