Multi-Binary Deployment & Fleets
In Hale, splitting a single-binary program into multiple communicating services requires zero changes to publisher or subscriber code. By adding a bindings { } block to main locus, you bind in-process topics to Unix sockets, shared-memory rings, or custom broker adapters, and use a fleet locus to verify architectural claims and render deployment manifests across the entire multi-binary topology.
type Match { size: Int; }topic MatchReady { payload: Match; }
locus Matchmaker { params { target_size: Int = 2; } bus { publish MatchReady; } run() { MatchReady <- Match { size: self.target_size }; }}
main locus App { bindings { MatchReady: unix("/tmp/matches.sock"); } run() { Matchmaker { target_size: 4 }; }}
fn main() { App { }; }Binding Topics to Transports (bindings { })
Section titled “Binding Topics to Transports (bindings { })”When a topic is not listed in bindings { }, the runtime delivers its messages through an in-process queue. Declaring the topic inside main locus’s bindings { } block routes it across processes while keeping Topic <- payload; and subscribe Topic as handler; unchanged.
| Transport | Syntax | Delivery Contract |
|---|---|---|
| In-process (default) | (omitted from bindings) |
Dispatched to every live subscriber inside the current process. |
| Unix domain socket | unix("/path", role: listen | connect) |
Framed AF_UNIX byte stream. Role is inferred from whether the binary subscribes (listen, up to 64 concurrent publishers) or publishes (connect); specify role: explicitly when one binary does both. |
| Shared-memory ring | shm_ring("/name", on_overflow: ...) |
Lock-free POSIX shared-memory ring for high-frequency same-host IPC. Slot claimed and committed under on_overflow policy. See Zero-Copy Bus. |
| Custom adapter locus | MyAdapter { ... } |
Any user or library locus implementing fn send(subject: String, bytes: Bytes) (__StdBusAdapter), such as NATS, MQTT, or WebSocket framing. |
| Operator UDP route | udp://host:port (in LOTUS_BUS_CONFIG) |
Configured at deploy time via LOTUS_BUS_CONFIG (generated by hale node), not in source. Supports IPv4 multicast; best-effort/lossy by design. |
Cross-Seed Topics & Keyed Routing
Section titled “Cross-Seed Topics & Keyed Routing”- Binding imported topics:
bindings { }can reference topics imported from shared library seeds (core::Verdict: unix("/tmp/verdicts.sock", role: listen);). - Keyed routing preserved: Topics declared with
keyed_byretain their routing behavior acrossunixsockets and custom adapters. The receiving process decodes incoming bytes once (through the binding’s codec, if present), extracts the routing key from the payload, and delivers the message only to matchingwhere key == ...subscribers. - Orphan topic analysis with
__local_dispatch: Custom adapters inject inbound network frames into the local bus by callingstd::bus::__local_dispatch(subject, bytes).hale checkcounts__local_dispatchwith a literal subject as a publisher of that topic, and__local_dispatchwith a dynamic subject string as a potential publisher of any subscribed topic in the binary.
Writing Custom Transport Adapters
Section titled “Writing Custom Transport Adapters”A custom adapter is an ordinary locus that provides fn send(subject: String, bytes: Bytes):
locus BrokerAdapter { bus { publish Outgoing; subscribe Outgoing as on_outgoing; } fn send(subject: String, bytes: Bytes) { Outgoing <- Frame { subject: subject, data: bytes }; } fn on_outgoing(f: Frame) { /* write frame to socket on adapter thread */ } run() { while !self.draining { /* read socket and call std::bus::__local_dispatch */ } }}bindings { BrokerEvt: nats::NatsAdapter { url: "nats://prod:4222" } codec(nats::JsonCodec { });}Adapter loci follow three rules:
- Constructed before
main: The adapter is instantiated once beforemain locus. Its literal cannot readself.<param>ofmain, executeterminate;, or publish to the bus during construction. - Pinned receive thread: The adapter runs on its own dedicated
pinnedOS thread. Itsrun()loop and any bus handlers it subscribes to execute on that thread, whereassend(subject, bytes)is called directly on each publisher’s thread. (To perform socket writes exclusively on the adapter’s thread, havesendpublish onto an internal topic that the adapter subscribes to, as shown above.) - Pinned lifecycle constraints: Like any
pinnedlocus, an adapter cannot declareaccept(...)or closures withbirthordissolveepochs (epoch inlineclosures are supported).
Connection Lifecycle, Supervision, and Teardown
Section titled “Connection Lifecycle, Supervision, and Teardown”Startup Verification & Connect Supervision
Section titled “Startup Verification & Connect Supervision”A broker never accepts messages onto a broken transport:
- Boot failure on unreachable binding: If any
bindings { }entry orLOTUS_BUS_CONFIGroute cannot be opened at startup (missing socket directory, port conflict, or exhausted connect retries), the binary exits non-zero at birth with a diagnostic naming the subject. - Disconnect handling on
listensockets: When a publishing peer disconnects from aunixlistener, the listener stays open and accepts subsequent connections seamlessly. - Supervising
connectsockets (std::bus::UnixTransport): If aconnectpeer drops mid-run, the publishing process exits structurally by default. To reconnect automatically instead, add anon_failurehandler forstd::bus::UnixTransportonmain locus:
type Reading { v: Int; }topic Evt { payload: Reading; }
main locus App { bindings { Evt: unix("/tmp/evt.sock", role: connect); } bus { publish Evt; } on_failure(t: std::bus::UnixTransport, err: ClosureViolation) { restart(t); // re-runs connect-with-retry } run() { Evt <- Reading { v: 1 }; }}
fn main() { App { }; }Blocking During Reconnect (or wait)
Section titled “Blocking During Reconnect (or wait)”While a supervised UnixTransport is reconnecting, publishes drop by default (incrementing dropped_lost in LOTUS_BUS_COUNTERS_DUMP=1). To park the publisher until the connection is restored, append or wait at the send site:
Evt <- reading or wait;- Each send site on a topic can choose independently between the default drop behavior and
or wait(tracked by thewaitscounter inLOTUS_BUS_COUNTERS_DUMP=1). - The single send that initially detects a broken pipe still triggers the structural failure so
on_failurecan start the reconnect;or waitparks subsequent sends during the reconnect window. - If reconnection fails permanently or the program begins shutting down,
or waitaborts with an error rather than hanging indefinitely.
Listener Buffering & Teardown Order
Section titled “Listener Buffering & Teardown Order”- Early-arrival buffering (
buffered_early): If a remote publisher connects and sends messages in the brief window after alistensocket binds but before local subscribers finish registering, the listener buffers up to 64 messages / 1 MiB per binding and delivers them as soon as registration completes. - Shutdown quiesce (
LOTUS_BUS_QUIESCE_MS): Whenmainreturns,listensockets stop accepting new peers and drain any unread kernel socket buffers for up to 500 ms (configurable viaLOTUS_BUS_QUIESCE_MS) while local subscribers are still live. - Binding teardown order: Transport bindings are initialized in
fn main’s prelude before any user code and are dissolved last at shutdown:- Quiesce
listensockets (LOTUS_BUS_QUIESCE_MS). - Join cooperative worker pools.
- Drain and dissolve user loci innermost-first (ensuring messages published inside
dissolve()hooks still reach the wire). - Dissolve transport bindings, closing sockets and joining transport threads.
- Quiesce
Wire Codecs (codec(...))
Section titled “Wire Codecs (codec(...))”By default, bound topics serialize payloads using Hale’s internal binary format. To interoperate with non-Hale services over JSON, Protobuf, or custom wire encodings, attach a codec(...) locus to the binding:
bindings { Tick: unix("/tmp/ticks.sock") codec(TickJsonCodec { }); Feed: BrokerAdapter { url: "broker://localhost" } codec(TickJsonCodec { });}A codec locus is checked structurally against the topic’s payload type (encode accepts the payload type and returns Bytes/String; decode accepts wire bytes and returns the payload type) and must be stateless (pure) so it can execute safely on transport threads.
Multi-Binary Verification (fleet locus)
Section titled “Multi-Binary Verification (fleet locus)”While hale check verifies each binary in isolation, a fleet locus models an entire multi-binary deployment—declaring which component binaries run, their host or replica placement, the transport bindings connecting them, and cross-binary architectural claims { }:
import "../desk" as desk;import "../venue" as venue;import "../shared" as t;
fleet locus Prod { params { orders: desk::Orders; gw: venue::Gateway; } placement { orders: host("box-a"); gw: replicas(2); } bindings { t::OrderRequest: unix("/run/orders.sock"); } claims { one_desk: count publishers(topic t::OrderRequest) == 1; no_return: forbid reaches(gw, orders); }}How Fleet Composition Works
Section titled “How Fleet Composition Works”- Artifact-level composition: A fleet seed never merges source ASTs across binaries (which would conflate in-process topics with cross-process routes). Instead,
hale fleet checkcompiles each referenced seed into its admittedComponentModelartifact and joins them strictly along the topics declared in the fleet’sbindings { }block. - Process instances (
<param>-<i>): Each parameter expands into concrete process instances based on its placement (orders-0onbox-a;gw-0andgw-1forreplicas(2)), allowingclaimsto reason about exact publisher/subscriber cardinalities across replicas. - Binding consistency checks: If an individual binary’s
main locusalso declares a binding for a fleet topic, both must specify the same transport. Any fleet binding lacking a publisher or a subscriber fails verification.
Register fleet seeds in hale.toml to verify all environments together:
[fleets]production = "fleets/prod"staging = "fleets/staging"Fleet CLI Commands & Cryptographic Signing
Section titled “Fleet CLI Commands & Cryptographic Signing”| Command | Description |
|---|---|
hale fleet check <fleet-dir> |
Extracts component artifacts, derives cross-binary routes, and verifies all fleet claims { }. |
hale fleet export <fleet-dir> |
Exports the resolved instances, seed hashes, hosts, derived routes, and claims as JSON. |
hale fleet keygen <name> |
Generates an ES256 signing keypair (<name>.pem and <name>.pub.pem). |
hale fleet sign <fleet-dir> --key <name>.pem |
Signs the exact fleet export bytes, writing <fleet-dir>/fleet.sig. |
hale fleet check <fleet-dir> --trust <name>.pub.pem |
Verifies fleet.sig (and component artifact hashes) before checking claims. Also configurable via [fleet_trust] keys = [...] in hale.toml. |
When a fleet claim fails, hale fleet check prints the exact cross-binary witness path:
fleet claim `orders_pass_oms` violated — witness: prober-0::Probe::submit [prober/main.hl] -(route `t::OrderRequest`)-> gw-0::Gateway::on_order [gw/main.hl]Platform Health Probes (std::observe::Probes)
Section titled “Platform Health Probes (std::observe::Probes)”To expose HTTP health and readiness endpoints for container orchestrators (Docker Compose, Kubernetes) even on binaries that serve no user-facing HTTP API, add std::observe::Probes to main locus’s params:
main locus App { params { probes: std::observe::Probes = std::observe::Probes { port: 9100 }; } run() { while !self.draining { std::time::sleep(100ms); } }}fn main() { App { }; }The runtime spawns a dedicated probe thread (isolated from application cooperative pools so a wedged pool never blocks health checks) serving three endpoints:
| Endpoint | Responses & Semantics |
|---|---|
GET /healthz |
Returns 200 ok, or 503 naming any queue (main or cooperative pool) where queued work has gone untouched for over 10 seconds (configurable via LOTUS_PROBES_STALL_MS). An idle queue or a single long handler with an empty queue remains 200 ok. |
GET /readyz |
Returns 503 starting while main’s children are birthing and bindings are opening, 200 ready during steady state, and 503 draining as soon as shutdown begins. |
GET /capacity |
Returns a JSON snapshot of each queue’s waiting cell count, maximum capacity, and current stall duration. |
std::observe::Probes must be initialized via its parameter default on main locus (the port may reference an earlier parameter, e.g., port: self.probe_port, but callers constructing main cannot pass a custom Probes instance).
Rendering Deployment Manifests (hale deploy render)
Section titled “Rendering Deployment Manifests (hale deploy render)”hale deploy render translates a seed or fleet locus into Docker Compose or Kubernetes manifests without building images or touching live infrastructure:
hale deploy render fleets/prod --env prodhale deploy render fleets/prod --backend compose --placement alloc.json --out deploy/hale deploy render ./svc --backend kubernetes --out deploy/Configure target environments in hale.toml:
[environments.prod]source_only = truebackend = "compose"
[environments.prod.env]LOG_LEVEL = "info"- Per-host manifest generation: For a
fleet locus,hale deploy renderoutputs one manifest per target host (<out>/<host>/). You can override static host placements by passing a JSON allocation file ([{"instance": "gw-0", "host": "box-a"}, {"instance": "gw-1", "host": "box-b"}]) via--placement. - Compose vs. Kubernetes mapping:
- Compose: Renders one service per instance on that host, mounting pre-built binaries (
hale buildeach seed first), addingdepends_onfor same-host routes, and recording cross-host routes (gw-1@box-b) underx-hale-routes. - Kubernetes: Renders one
Deploymentper program withreplicasset to its instance count on that host and annotates cross-host routes in comments.
- Compose: Renders one service per instance on that host, mounting pre-built binaries (
- Automatic probe and metrics wiring: If a program declares
std::observe::Probes,hale deploy rendergenerates the corresponding Composehealthcheckor Kubernetes liveness/readiness probes (using the default value ofself.probe_portif parameterized). If a program declaresstd::observe::Scrape, the manifest publishes the metrics port and addsprometheus.io/*scrape annotations.
Next: Composition Patterns.