Deployment
One binary is the engine, the CLI and a node, with the web UI embedded. It runs the same packages everywhere, and the environment file says where. Each customer gets a deployment of their own, on their infrastructure.
On a laptop
millstream run in a package directory runs the engine and an embedded node on SQLite and serves the API and the UI
on 127.0.0.1:7070. There is nothing to configure and no service to install.
millstream run # engine, embedded node, API and UI
millstream run --once # fire the manual triggers, wait for the runs, print a summary, exit
millstream init environment # millstream.yaml, every key commented out at its default
On a server
The same binary runs with serve against Postgres. The repository ships a Docker Compose file with Postgres, an
active engine, a standby engine and two nodes, and systemd units for hosts without containers.
cd deploy/compose
cp .env.example .env # the proxy's URL, and openssl rand -hex 32 for each secret
docker compose up -d
millstream apply ./property-sourcing --server https://millstream.internal
environment: production
store: { dsn: postgres://db.internal/millstream }
artifacts: { url: s3://millstream-artifacts }
listen: 127.0.0.1:7070
auth: { token: { env: MILLSTREAM_AUTH_TOKEN } }
node: { embedded: true, token: { env: MILLSTREAM_NODE_TOKEN } }
retention: { events: 90d, transcripts: 30d, rollups: 400d }
Every key of the environment file is also a MILLSTREAM_* variable, so Compose, systemd and a shell configure it the
same way. Postgres and S3 credentials come from their libraries' own variables, never from the file.
A hot standby
A second millstream serve on the same Postgres store is a hot standby. Leadership is a Postgres advisory lock held
on the engine's only write connection, so there is never a second writer, whatever the clocks or the network do. When
the active engine dies or loses its session, the standby takes the lock, rebuilds its state from the store and starts
dispatching. While it waits, it serves the reads and the UI.
- ~30 ms for the standby to take the lock after the active engine dies, in the soak
- 15 s at most for a cut-off engine to step down, with the default settings
- 0 items lost or published twice across 3 engine kills and 3 node partitions
Remote nodes
A node is the same binary on any host. It joins the engines with the deployment's node token over TLS, pulls the deliveries it can run, and follows the active engine across a failover.
MILLSTREAM_NODE_TOKEN=… millstream node --join https://engine:7070,https://engine-standby:7070 --labels gpu=true,zone=eu
- Leases are the capability. A node fetches a package or an artifact only for a delivery it currently holds.
- Fencing. A lease carries a token; anything a node reports under a lease it lost is refused.
- Placement. Steps and pipelines ask for node labels, such as a GPU.
- Revocation.
millstream nodes revokerefuses a node from then on and expires its leases at once.
Several pipelines, one deployment
A deployment runs many isolated pipelines, each with its own secrets, limits and lifecycle. Dispatch is round-robin
across pipelines, so a backlog in one never starves another. apply uploads a package as a new revision,
rollback reactivates an earlier one, and runs in flight finish on the revision they started with.
Retention, erasure and backups
- Retention per stream expires payloads and artifacts, while the item's row and lineage stay as a tombstone. Retention never removes what unfinished work still needs.
- Erasure by key follows lineage downstream and across pipelines, and finishes before it reports.
- Backups are the database's and the artifact store's own:
pg_dump, and the bucket's versioning or replication. - Upgrades roll the standby first, then the active engine, then the nodes, each draining on SIGTERM: the standby takes over while the active one restarts.
Built to be taken apart
The crash suite kills the engine and its nodes at every boundary of every atomic unit, on SQLite and on Postgres, and checks the invariants after each: no item lost, no output published twice, every run settles, and both stores reach the same state. The chaos suite kills active engines, partitions nodes behind a fault-injecting proxy and skews their clocks. The soak runs the Compose deployment under random kills and partitions:
| Run | Chaos | Runs | Items | Kills | Partitions | Standby lock | Checks |
|---|---|---|---|---|---|---|---|
| 30 minutes | every 2 min on average | 168 | 15,837 | 3 engines | 3 nodes | 29–30 ms | all pass |
| 5 minutes | every 20 s on average | 27 | 2,528 | 2 engines, 4 nodes | 1 node | 30–31 ms | all pass |
Packaging
Artefacts
- A distroless static image, non-root
- Archives for Linux and macOS, amd64 and arm64
- An SPDX SBOM per archive
- Signed checksums and images from the first release
Targets
- Laptop: SQLite and an embedded node
- VPS: Postgres, standby and nodes with Compose
- Bare hosts: systemd units for engine and node
- Any S3-compatible bucket for artifacts
Requirements
- Linux or macOS
- Postgres for a server deployment (the Compose file ships 17)
- Docker or Podman for containers and sandboxed agents
- A reverse proxy for TLS, or the engine's own