Topology
A topology.toml next to forge.toml says which function or actor implements each
offered protocol role, which pools of nodes serve which roles, what each pool may
do, and (per environment) which hosts run each pool. forge topology check validates
it against your sources with errors that point into the file; forge topology export
turns it into JSON for other tools; forge topology gen writes systemd units, firewall
rules or a compose file from it.
A topology app has no hand-written main: the compiler generates it from the
topology (see Running a topology app). forge run runs every
pool in one process; forge run --processes runs one process per pool as a local
cluster. This is build steps 7 and 3 of the
distributed-deploys plan;
forge deploy --plan, epoch drains and the reconciler are later steps.
The file
# topology.toml
[roles]
"Checkout.Ledger" = { body = "Ledger.serve_one", capacity = 64 }
"Thumbs.Render" = { body = "Render.render_one", capacity = 8, place = { on = "gpu" } }
"Quotes.Server" = { actor = "Quotes.ServerActor" } # stateful: an actor per offer
[pool.edge]
start = "Edge.start" # a pool hook; runs on every node of the pool
public = [443] # the only ports open to the internet
# `initiates` and `caps` omitted: derived from what Edge's code reaches
[pool.ledger]
serves = ["Checkout.Ledger", "Quotes.Server"]
caps = ["IO.FileWrite"] # written, so enforced as an upper limit
[pool.imaging]
serves = ["Thumbs.Render"]
isolate = true # its own build; no role shared with another pool
[drain]
soft_ms = 30000
hard_ms = 120000
[roles] is the application’s wiring and changes with the code. [pool.*] is
placement and changes with the deployment. A role key is always "Protocol.Role",
where Protocol is an @[endpoints] protocol declared somewhere in the project and
Role one of its parties. Names in strings are fully qualified from the file’s
top-level module (Shop.Ledger.serve_one for fn serve_one inside mod Ledger
inside mod Shop).
| Section | Key | Meaning |
|---|---|---|
[roles] "P.R" |
body |
The function that serves one session of the role. Exactly one of body/actor. |
actor |
The actor that serves the role (one per offer). | |
capacity |
Sessions served at once (positive integer). | |
place |
{ on = "label" }, { count = n }, or both. No place: offered on every node of the pools that serve it. |
|
set |
Reserved for role sets (one role played by a set of nodes). | |
[pool.<name>] |
start |
The pool’s hook: hand-written startup, called by the generated main on every node. |
serves |
["P.R", ...], or "*" for every role in [roles]. |
|
initiates |
Roles the pool’s code may initiate. Omitted: derived from <P>_Run.initiate_<R> call sites reachable from the hook and the served roles’ bodies. Written: an upper limit the check enforces. |
|
caps |
Capabilities the pool’s user code (its hook and its roles) may use. Omitted: derived by the compiler from what that code reaches. Written: an upper limit the compiler enforces. | |
isolate |
true: its own build and sandbox; no role it serves may be served elsewhere. |
|
public |
Internet-facing ports. | |
main |
Escape hatch: a hand-written entry file for this pool. Warns, since it gives up forge run’s single-process composition and runtime-owned offers. |
|
hosts |
Usually in an overlay: ["user@host", { host = "user@host", labels = ["gpu"] }]. |
|
replicas |
For backends that schedule instead of taking a host list. | |
[drain] |
soft_ms, hard_ms |
Stop taking new work, then kill. hard_ms must be at least soft_ms. |
[backend] |
kind |
"ssh" (a host list per pool) or another process orchestrator. |
port |
The cluster port the firewall generators open between all cluster members (default 7946). |
Unknown keys are errors, with the file and line:
topology.toml:7: unknown key 'publik' in [pool.edge]
Environment overlays
topology.<env>.toml merges onto the base file: tables deep-merge (an inline table
too), and an array in the overlay replaces the base’s array. --env prod on any
forge topology subcommand applies topology.prod.toml; forge deploy hot --env prod
applies it when it exists.
# topology.prod.toml
[backend]
kind = "ssh"
[pool.edge]
hosts = ["root@web-1"]
[pool.imaging]
hosts = [{ host = "root@render-1", labels = ["gpu"] }, "root@render-2"]
Placement rules
- No
place: offered on every node of the pool. on = "label": offered on the nodes whose host carries that label.count = n: offered on n of the live nodes that serve the role, chosen at run time by rendezvous hashing over cluster membership: every node ranks the candidates the same way and offers when it is in the top n. When a node is declared dead, the next one in the ranking takes the role over. A node that rejoins counts again only afterMARCH_PLACEMENT_SETTLE_MS(default 15000), so a flapping node does not pull roles back and forth.count = 1is not a lock: during a partition each side may offer.- Both:
countranks only the labelled nodes.
forge topology check
Runs on its own and automatically at the start of forge build, forge run and
forge deploy hot whenever a topology.toml exists. On success it writes the digest
.forge/topology.json. It rejects, each with file:line:
- a malformed file, or an unknown key or section;
- a role key that is not
"Protocol.Role", a role with neither or both ofbody/actor, a non-positivecapacityorcount, a port outside 1-65535; - a
body,actororstartthat names no declaration in the project’s sources; - a role whose protocol or role name no
protocoldeclaration has, in[roles],servesorinitiates; - a served role with no
[roles]binding, and a bound role no pool serves; - a
place.onlabel no host of the serving pools carries, and aplace.countabove the number of (labelled) hosts, once an overlay supplies hosts; - an
isolate = truepool that shares a role with another pool; - a written
initiatesthat omits a role the pool’s code initiates (anything written is an upper limit).
It warns about a main escape hatch, a pool that serves nothing and has no hook, a
source file that does not parse (its names cannot be resolved), and, for every
protocol the topology names, unlabelled steps: a step without a label gets a
positional name (Msg_A_B_2) that renumbers when a step is added before it, which
changes its wire tag on a hot deploy. Label each step: order: Client -> Ledger : Int.
The compiler side, march --topology .forge/topology.json (which forge build and
forge run pass), refuses any schema version but 1 and checks, against the loaded
modules, that:
- every bound name exists;
- a
bodytakes the pool’s environment (its hook’s return type, or()without a hook), the session, oneCap(P)perrole R needsentry of its protocol, and the entry state; - a bound actor’s
inittakes the pool’s environment, and the actor handlesStart,DeliverandCancel(below); - a hook takes
Cap(P)parameters, then theCap(ClusterNode.Live), and declares its return type; - a role’s grant, and a hook’s
Capparameters, fit within a writtencaps; - after typechecking, what a pool’s hook and roles actually reach fits within a
written
caps, naming the hook or role that reaches beyond it; - with
--topology-isolate-foreign, no role or hook needingIO.Foreignsits in a pool that is notisolate = true.
A hand-written main is kept, with a warning: it gives up running every pool in one
process and the node-owned offers.
Editor support
march-lsp serves topology.toml and topology.<env>.toml (it recognises them by
name; see lsp/docs/editors.md to attach it). It reads them with forge’s own parser
and runs forge’s own checks, so what the editor shows is what forge topology check
prints:
- Diagnostics, with forge’s message on the line forge names. An overlay shows
what
forge topology check --env <env>reports in the overlay. The base file shows whatforge topology checkreports, plus what each overlay next to it adds in the base file (aplace.countabove the host count only exists once an overlay supplies hosts); those carry the sourceforge topology --env <env>. The names resolve against the open editor buffers, so editing a.marchfile (renaming the function abodynames, say) updates the topology file’s diagnostics without a save. - Go to definition on a
body,actororstartstring jumps to the declaration it resolves to. On a"Protocol.Role"string (a[roles]key, aservesorinitiatesentry), the protocol part jumps to theprotocoldeclaration and the role part to the role inside it: itsrole R needsline, else its first message. - Completion inside those strings: functions for
bodyandstart, actors foractor,Protocol.Roleforserves,initiatesand new[roles]keys, host labels forplace.on. Outside strings, the known keys of the section (or inline table) the cursor is in, and section names in a header, including the base file’s pool names when an overlay starts a[pool.. - Hover on a role string shows its
role R needs ...grant and its body type,(Cap(Session.Live), Cap(P)..., <P>_<R>.Entry) -> <P>_<R>.Yield, with the pool environment a topology-boundbodytakes first.
forge topology export --json
Prints the digest plus the derived facts:
derived.<pool>.capsandderived.<pool>.initiates: the capabilities the pool’s hook and roles reach and the roles its code initiates, as the compiler derives them ("source": "compiler"). When the compiler cannot run (the program does not typecheck, say),capsisnull,initiatesfalls back to a by-name search of the sources ("source": "names"), and forge says why;connectivity: which pools talk to which, and over which protocols. Two pools are connected when a role one of them serves or initiates exchanges a message with a role the other does, the same rule the generatedpeers_<Role>()uses. A pool whose own roles talk to each other has an edge to itself, which matters when the pool spans several hosts;cluster_portand each pool’spublicports.
The JSON schema is documented in specs/features/topology.md.
forge topology gen <target>
Built-in targets, each written to stdout behind # ==> <file> <== markers, or under a
directory with --out DIR:
| Target | Output |
|---|---|
systemd |
One march-<pool>.service per pool, run as User=march: MARCH_POOLS, MARCH_TOPOLOGY_FILE, the status file, the reload socket and HOME, an EnvironmentFile for secrets, TimeoutStopSec from [drain] hard_ms. forge host init writes one per host with that host’s own settings too. |
ufw |
One ufw-<host>.sh per host: ssh, its pool’s public ports from anywhere, the cluster port from every other host in the topology. |
do-firewall |
do-firewalls.json: one DigitalOcean firewall per pool, keyed by droplet tag march-<pool>, with the same rules. |
compose |
docker-compose.yml: one service per pool, replicas from the host count, ports from public. |
Any other target runs forge-topology-<target> from PATH with the export JSON on
its stdin, the same convention as forge-<subcommand> plugins, so a Kubernetes or
Nomad generator can live outside forge.
forge topology gen systemd --env prod --out deploy/systemd
forge topology gen ufw --env prod
forge topology gen k8s # runs forge-topology-k8s if it is on PATH
The cluster port is open between every pair of members, not only between pools that
exchange protocol messages. SWIM membership probes and gossips with every member, and
every node is given every other node as a seed, so a firewall cut along the connectivity
graph would make two pools that share no protocol see each other as unreachable, and
count = n placement would rank on a wrong membership. Pools are kept apart by
certificate instead: each node’s certificate names its roles, and roles, raw sends and
cross-node references are checked on every frame (see Cluster
certificates). Every other port stays
closed between pools: a pool’s public ports are open to anyone, and the control port
([control]) only between candidates.
Running a topology app
In a topology app the compiler generates main. For each pool it contains, the
generated main:
- starts the cluster node from the environment (
MARCH_NODE_NAME, defaultlocal-1;MARCH_NODE_PORT, default 7946;MARCH_CLUSTER_NODES, the seeds); - runs the pool’s hook, passing one narrowed capability per
Cap(P)parameter it declares, then the node handle; - opens the offers the topology places on this node, re-evaluating as nodes join and leave;
- on SIGTERM or SIGINT, closes every offer, lets running sessions finish, and exits
(
[drain] hard_msis the most it waits); - offers a role again if its offer, or the actor serving it, dies.
A process of a shared build runs the pools named in MARCH_POOLS (comma-separated),
or every pool when it is unset. MARCH_NODE_LABELS (comma-separated) are the labels
place = { on = ... } looks for.
Hooks
A pool’s start is where hand-written startup goes. It runs on every node of the
pool, before any offer opens, and its return value is the environment every role of
the pool receives:
mod Back do
type Env = { factor : Int }
fn start(_con : Cap(IO.Console), _node : Cap(ClusterNode.Live)) : Env do
{ factor: 10 }
end
end
A hook must return promptly. Start long-lived work (a server, a loop that
initiates sessions) in a task or actor. A hook still running after
MARCH_HOOK_TIMEOUT_MS (default 10000) is reported by name and the process exits 1.
Role bindings
A function binding serves one session: the pool’s environment, the session, the role’s granted capabilities, the entry state:
-- protocol Echo do role Server needs IO.Console ... end
fn serve_one(env : Env, s : Cap(Session.Live), con : Cap(IO.Console),
st : Echo_Server.Entry) : Echo_Server.Yield do ... end
An actor binding (actor = "...") holds state across sessions. The generated code
spawns one per offer with the pool’s environment as its init argument, and forwards
each session to it:
actor CounterActor do
state { total : Int, sessions : LinearMap(String, Count_Counter.Parked_Counter) }
init(env : Env) { total: 0, sessions: LinearMap.empty_string() }
on Start(sid : String, s : Cap(Session.Live)) do ... end -- plus the role's granted caps
on Deliver(sid : String, s : Cap(Session.Live), from : Int, msg : Bytes, ep : Int) do ... end
on Cancel(sid : String, s : Cap(Session.Live), role : Int, cause : String, ep : Int) do ... end
end
These are the three messages a hosted access point’s actor takes
(offer_hosted_<Role>, see the Choreography chapter).
A node offers each protocol role (at one protocol version) once: the offer’s endpoint
is named by protocol, role, version and node, not by the topology role. Two roles placed
on one node that serve the same protocol role through the same offer_<Role> share that
endpoint, so only the first is offered. The node reports the other once
(topology: <role>: cannot be offered on <node>: its endpoint name is held by role ...),
lists it in its status file as conflict <role> held-by <role>, and retries it with
backoff; it is offered, with a report, once the holder’s offer closes. Give each role
its own protocol, or place them on different nodes.
forge run
forge run # every pool in one process
forge run --processes # one process per pool, a cluster on this machine
A topology app always runs compiled, even when forge run would otherwise
interpret: the runner that serves roles is compiled-only, and forge says so. In one
process, every pool’s roles are served by the same node, and a session whose roles
are all local runs over the node’s loopback link. An initiator prefers an offer on its
own node.
--processes builds once for the pools that share a build and once per isolated pool,
then starts one process per pool (one per host, when an --env overlay lists hosts),
named <pool>-<n>, each on a free port with every other process as a seed, and prints
their output prefixed with the process name. Logs are kept under .forge/run/.
Ctrl-C drains and stops every process; --fail-fast stops them all as soon as one
exits.
examples/topology_app is a complete two-pool app.
Changing placement on a running system
A node opens its own offers from the topology it was given, and it re-reads that
topology on SIGHUP. The reconciler (at small scale, forge on your machine) never
opens or closes an offer itself: it writes the new topology and signals the nodes, and
each node decides what it now serves. A placement change therefore needs no code change
and no restart.
forge run --processes --env dev # in one terminal: the cluster, one process per pool
# edit topology.toml or topology.dev.toml: place = { on = "b", count = 1 }
forge topology apply --env dev # in another: one reconciliation pass
forge topology status # what each node holds
forge topology apply is one pass: it loads and checks the topology (with the overlay
the cluster was started with, unless --env says otherwise), diffs it against what the
nodes were given, pushes it (.forge/topology.json rewritten, SIGHUP to every node),
waits until every node reports the new digest and its offers have settled, and prints
each node’s offers:
1 change(s), none needs a restart:
Echo.Server: placement count 1 -> count 1 on a
pushed c5d272a26c83 to 3 node(s)
a-1 (pool a, pid 83370, port 57968)
topology: c5d272a26c83
offers: Echo.Server
b-1 (pool b, pid 83371, port 57969)
topology: c5d272a26c83
offers: (none)
What a node applies from a re-read topology, for the roles its build contains:
- a role’s placement (
place): a role this node now ranks for is offered; one it no longer ranks for is closed and drains (new invitations are refused, running sessions finish; the closed offer is reported at the topology’s soft and hard deadlines if sessions are still running); - a role’s capacity: the offer is closed and reopened at the new size;
- a role a pool stops serving, or a role removed from
[roles]: its offers close and drain; - the
[drain]deadlines for offers closed from then on.
What needs a rebuild and restart, and is refused by apply with the list: a role’s
binding (body/actor), a new role, a role newly served by a pool (its build has no
code for it), a pool’s hook, caps, initiates, isolate, hosts and labels (a node
reads its labels when it starts), pools added or removed. Stop the cluster and run
forge run --processes again.
Two forge invocations cannot reconcile the same project at once: a pass holds
.forge/run/reconcile.lock, and a second one reports who holds it. The running
cluster is recorded in .forge/run/state.json (pids, ports, reload sockets, each
node’s status file) while forge run --processes lasts. Each node reports the digest it
applied, its offers and its running sessions to .forge/run/<node>.status
(MARCH_TOPOLOGY_STATUS); a node that has not reported yet is never signalled, since a
process with no watcher would die of the SIGHUP.
Outside forge, the same works by hand: write the digest to the file the node was started
with (MARCH_TOPOLOGY_FILE) and send it SIGHUP. A node that has received a signed
TOPOLOGY push reads that verified copy instead, and a restarted node comes back on it
(see Hot code reload).
Deploying
A topology whose overlay says [backend] kind = "ssh" is deployed to the overlay’s
hosts by forge deploy, one reconciliation pass per run, with forge on your machine as
the reconciler (there is no daemon).
# topology.prod.toml
[pool.back]
hosts = [{ host = "root@back-1", labels = ["db"] }, "root@back-2"]
[pool.front]
hosts = ["root@front-1"]
[backend]
kind = "ssh"
Once per host: forge host init --env prod. Over ssh, idempotently (a second run
changes nothing):
- the
marchsystem user and/opt/march/<project>(code),/var/lib/march/<project>(the service’s HOME: its CAS root keeps the persisted patch stack, andrun/holds the reload socket and the status file),/etc/march/<project>(configuration); /etc/systemd/system/march-<pool>.servicefrom thesystemdgenerator, with this host’sEnvironment=: node name (<pool>-<host>), labels, cluster port and advertised address, the other nodes as seeds, the reload socket, the status and topology files,MARCH_DEPLOY_POLICY; enabled where systemd runs;- the deploy public key; the cluster secret (generated once per environment, in the
pool’s env file, mode 0640), or, when an operator key exists (
forge cluster keygen;.forge/cluster/operator.keyor--operator-key), a node certificate per node, issued for the roles the pool offers and initiates and reused until 30 days before it expires; - the node’s capability policy (
<pool>.policy, the admission gate’sMARCH_DEPLOY_POLICY) from the pool’s writtencaps, else the compiler’s derived ones; the gate also applies it to each patched function’s own capabilities; - the pool’s ufw rules (applied when ufw is installed;
--no-firewallonly writes them), and the DigitalOcean firewall JSON locally in.forge/hosts/prod/; - each host’s target (
uname -sm), recorded in.forge/hosts/prod.json.
One pool per host: a host listed in two pools is refused.
Every deploy: forge deploy --env prod. It builds each build (the shared one, each
isolated pool’s) for each target its hosts recorded, compares it with what was last
deployed there (.forge/deploy/prod/), prints the plan, asks, and carries it out.
--plan prints the plan and stops:
forge deploy --plan (env prod)
1. What changed
build shared (pools back, front): 1 function(s) changed, 0 added, 0 removed
changed: Back.scale
2. Mechanism and why
pool back (build shared, 1 host): hot patch
1 function(s) changed, 0 added, 0 removed
...
3. Order and splits
1. pool back (hot patch)
...
4. Drains
none
5. What may be lost
nothing
6. Authority and derived values
no capability widens
pool back: caps IO.Console; initiates (none)
...
Per pool, the mechanism is the strongest that applies:
| Mechanism | When |
|---|---|
| blocked | a state change with no migrate_state under its @compat, a migrate_msg for the wrong old type, a widening capability without --grant-cap |
| restart | nothing deployed yet; a pool hook changed (hooks run once, at start); the C runtime, HCR ABI or target changed; a binding, a new role or pool, hosts or labels changed; code the running base cannot swap; compaction |
| hot patch + protocol drain | a protocol’s wire fingerprint changed: its offers close and drain |
| hot patch + migration | an actor’s state changed and migrate_state exists |
| hot patch | functions changed |
| topology push | only placement or capacity changed |
A choice that gains a branch is safe when every receiver of the choice runs the new version before its chooser does, so pools that receive it go first. When one build both chooses and receives it (a replicated monolith), no order works within one deploy: the plan splits it in two (D21) and says so under “3. Order and splits”:
SPLIT (D21): build shared both makes and receives Echo's changed choice (`choose by Server`
gained `retry`), so no order within one deploy puts its receivers first: the change is two deploys
deploy one (expand) <- this deploy: every build compiled with --protocol-expand Echo:retry. The
receivers (Echo.Client) run the new version (fingerprint 1edebff1) and accept the previous one;
Echo.Server keeps offering and initiating under the previous fingerprint 181c4b72 and cannot
choose `retry`
deploy two (contract): the plain build, once every host runs deploy one. Echo.Server moves to
fingerprint 1edebff1 and may choose `retry`; `forge deploy` stops after deploy one: run it
again for deploy two
forge deploy stops after the expand; running it again for the same source is the
contract. In the expand build the chooser’s choose_<label> panics, so chooser code that
must run in both builds asks first:
<P>_<Chooser>.may_choose_<label>() is false
until the contract. Which changes are compatible is the compiler’s rule, computed over
wire tags against what the environment runs (.forge/deploy/<env>/protocols/, never the
build-to-build .forge/protocols/). Anything else is breaking: every node offers both
fingerprints while the deploy rolls through, and the plan says why, naming the unlabelled
messages a new branch would renumber.
forge topology status --env prod and forge topology apply --env prod work over ssh
too: status reads each node’s report, its reload server (code versions, pins, what its
last start restored, the patch stack’s size, its target) and says whether the node runs
what forge last deployed; apply pushes a placement-only change, the signed TOPOLOGY
verb on each node’s reload socket, then the digest file and a SIGHUP to its unit, and
refuses a change that needs forge deploy.
forge deploy --compact rebuilds each build’s base image from the current version and
restarts its hosts onto it, clearing their persisted patch stacks; [hot-reload]
compact_after = N in forge.toml does it when a node’s stack grows past N.
ssh is plain ssh from PATH (your ~/.ssh/config applies); FORGE_SSH_CONFIG=<file>
adds -F <file>. Scripts run as root, or through sudo -n.
The multi-host lab (scripts/lab/run.sh) does all of
this against four local containers, with a three-role choreography app
(examples/lab_app),
and checks hot deploys through the control plane, restarts and failover.
Testing an upgrade
forge test --upgrade-from v1.4.0 # any git ref
An upgrade test runs the old version of the app as local processes, drives traffic through it, hot-deploys the working tree into those processes, and checks what the deploy did:
git worktree addat the ref under.forge/upgrade/<ref>(the directory ignores itself), built with hot reload and started likeforge run --processes, each process with a reload socket;- every
test/upgrade_*.marchof the working tree is compiled and started as one more node of that cluster. It is the traffic: it opens sessions, sends actors messages, and makes its own checks. It is givenMARCH_UPGRADE_SOCKETS(the reload sockets),MARCH_UPGRADE_READY(a file it creates once its pre-upgrade traffic is running) andMARCH_UPGRADE_DEPLOYED(a file forge creates once the new code is live), and it exits 0 when its checks pass; - the working tree is deployed into every process through the same path as
forge deploy hot, on the local socket; - forge waits for the tests to finish and for the drain, then reads each process’s
PINS.
The test passes when every test file exited 0, every old process is still running, and
the counters show nothing dropped, nothing killed by a hard drain deadline and no
epoch marker lost. A dropped message is the typical broken upgrade: an actor whose
message type changed while old code (a task started before the deploy, another node)
still sends it the old format, with no <actor>_migrate_msg to convert it. The report
names it:
upgrade from HEAD FAILED:
app-1 dropped 255 message(s): an actor's message type changed and old code still
sent it the old format, with no migrate_msg for it (write one: forge hot-reload
migrate-msg-stub <Actor>)
Actors that hold their epoch for an unfinished session, or sit in a nested receive,
and units that are not actors (tasks) stay on the old epoch until a hard drain deadline,
which is off by default; the report lists them, and MARCH_UPGRADE_STRICT_DRAIN=1 makes
them a failure. MARCH_UPGRADE_DRAIN_S (60) bounds the wait for the drain,
MARCH_UPGRADE_TEST_S (180) the wait for the test files.
A test file declares the protocols it drives itself (a protocol’s wire identity is its
name, roles and steps, so a copy interoperates with the app’s), starts its node with
Topology.config_from_env(), and initiates sessions as the app’s own initiators would.
forge/test/fixtures/upgrade
holds a complete one: v1 (the old version and its test/upgrade_traffic.march),
good (an upgrade that passes) and drops (one that drops messages and fails).