Running a cluster
Pre-alpha. Ownership, forwarding, replication, failover and deploys work and are tested (simulations in
sim/and multi-node tests), and have run against Cloudflare R2 and OVHcloud Object Storage, with nodes killed under load and nothing lost (measured), but not yet under production load or against AWS S3. Do not trust a cluster with data you care about yet (see the roadmap).
Nodes share one S3-compatible bucket (AWS S3, Cloudflare R2, MinIO, …) that
supports conditional writes (If-None-Match / If-Match). There is nothing
else to run: no consensus cluster, no coordinator.
For the short version of what callers see when a node stops or dies, and of what adding nodes does, read When a node dies, and how it scales.
How it works
- Each node writes a heartbeat to
nodes/<id>with its address, every heartbeat period (at most a third of the lease ttl). It is the node’s lease (ADR 0020): written only if the object is still this process’s last write, so a node finds out at its next write when another writer replaced it, and stops. A node runs actors’ events and is ready only while that lease is valid (10 s after its last write, minus a 2 s safety margin). The lease timings tolerate clock skew between nodes (seewasmkeep_cluster::node_lease, proven in simulation). - Each actor has an ownership record in the bucket at
owners/<tenant>/<class>/<name>, naming the node (and its process) that holds it, with a generation of its own for every hold. A node writes it when it takes the actor (its first event after waking) and when it gives it up (hibernation, a drain, a move, a clean stop): there are no renewals while the actor stays in memory. A request that reaches a node not holding the actor is forwarded once to the node the record names, if that node is live. Responses carryx-wasmkeep-node: <id>. If no connection to that node can be opened, or the connection fails before an answer, the client gets a 503 (“the actor’s node did not answer; retry”) and the request is not sent again: whether or not it was written, since a request that was written may have run there, and the 503 does not say which it was. Only a connection kept open that the node had already closed costs nothing: it is replaced before the request is written. (One exception: a WebSocket upgrade that node provably never read, because it refused the TLS handshake or the upgrade came back unsent on a new TLS connection, is sent once more, the client waiting meanwhile; see “A caller its peer briefly stopped trusting” below.) A release that fails is retried in the background; until it lands, the record keeps naming the node. - A node that stops cleanly (
SIGTERMor Ctrl-C) finishes its in-flight events, then releases its actors: the next request for one of them starts it on another node at once. Last, it writes its heartbeatreleased, which no reader counts as live, and which frees any record whose release did not land. - If a node dies, a peer that saw its heartbeat unchanged for a lease period plus a write timeout (a third of the ttl plus 2.7 s, 6 s by default), and once 15 s (the ttl plus a 5 s grace period) have passed since its last write, writes it fenced: no reader counts that node live any more, its actors are free, and the next request for one of them starts it on another node (15 to about 21 s after the node’s last write). If its process was only cut off from the bucket, its next write fails and it stops (a service manager restarts it). A node whose own lease runs out (the bucket unreachable for 8 s) drops every actor at once and takes them again, at new generations, once its lease is valid again. After a bucket outage, a node renews its own lease and waits a lease period plus a write timeout again before it fences anyone, so a node that regained the bucket at about the same time has renewed by then. One cut off for longer is fenced, and restarts. Meanwhile a node that gets a request for one of the dead node’s actors answers 503, or keeps the request until the actor is free: see “When a node dies: what a caller sees” below.
- A deleted heartbeat frees nothing: the actors its node held stay held
until a process starts with that id (its start check waits, then it
takes them back) or the node is fenced:
wasmkeep cluster fence <node>writes a fence even with no object, and the actors are free 15 s later. Node leases, ownership records and markers are never collected;wasmkeep cluster forget <node>removes only a retired node’s heartbeat in the format from before node leases, once it is older than 25 s.
When a node dies: what a caller sees
Between a node’s death and the takeover of its actors (15 to about 21 s after its last lease write, with the defaults), no node can run them. A surviving node that gets a request for one of them does one of two things.
It keeps the request and answers once it took the actor over, for a
GET or HEAD without a body and for a WebSocket upgrade that arrive
from outside. The caller sees one slow answer (the actor’s own, with
x-wasmkeep-node naming the new owner, or the 101) instead of a 503 to
retry. The takeover is the ordinary one, and the actor is free at the
same moment as without the wait: nothing about leases changes, the wait
only saves the caller the retries. A kept request asks for the actor up
to a second after this node learns it is free (the requests kept for
different actors are spread over that second), which is at its own fence
of the dead node or, when another node wrote the fence, at its next
heartbeat listing (up to 3 s later). A request is kept when the node
has a reason to think the owner is gone or going:
- the owner’s heartbeat left this node’s view (10 s after its last write) and has not moved since;
- the owner is still in view, but a connection to it could not be opened from this node, and no lease write of it since then proves it alive;
- the owner is fenced (by a node or by
wasmkeep cluster fence), or stopped and released, and its actors are not free yet.
It answers 503 with Retry-After: 1 at once, as before, for
everything else: a request with a body, any other method (POST, PUT,
DELETE, …), an actors.call (the calling actor gets its error), a
request another node forwarded, and any request over the caps below. Such
a 503 still means the request was not run here; nothing with a body is
ever kept, so a write is never run late for a caller that gave up. An
owner that is alive keeps its actors: a node that renews its lease is
never taken from, however slow it is to answer, and a request for one of
its actors is sent to it as before. The one exception is a node that is
alive but to which this node could not open a connection: requests for
its actors are kept for a few seconds, until its next lease write shows
it alive (see “An owner that is alive but refuses connections” below).
A kept request ends in one of these ways:
- served by this node, which took the actor over;
- sent, once, to another node that took it over first, and answered by it;
- sent to the owner as usual, because the owner turned out to be alive (it wrote its lease again, or restarted);
- 503 “the actor’s node is not reachable; retry” with
Retry-After: 1when the wait runs out, when this node begins to stop, or when its own lease runs out. Never a 504 from the wait itself; - dropped, and never run, when its client closes the connection first.
--failover-wait-secs sets how long a request is kept at most. The
default comes from the lease timing: the ttl plus grace (15 s), the watch
before a fence (6 s), one heartbeat period for a node that did not write
the fence itself (3 s) and a second over which the kept requests are
spread, 25 s in all. --failover-wait-secs 0 turns the wait off: every
request gets the answer it got before, at once. A node keeps at most a
quarter of its connection limit and 64 requests per actor; a request over
either gets the 503 at once. The connection limit is --max-connections
(10,000 by default, so 2,500 kept requests) after serve lowered it to
fit the open-file limit: a node left at a limit of 1,024 files keeps far
fewer, and one whose quarter comes to zero keeps none (counted as
over_cap). Nothing identifies the
caller before a request is kept, so anyone who can reach the node can
fill the places for a dead node’s actors; later callers then get the 503
at once, as without the wait. A kept request holds its connection and its
headers, nothing else: no actor is loaded for it, and no bucket request
is made for it while the owner’s heartbeat is only stale. While the owner
is suspected after a failed connection, or stopped and released, each
kept request reads the actor’s ownership record about once a second, as
a client retrying every second would; once the actor looks free it asks
for it, about once a second until it has it. Each node decides by itself, nothing travels
between nodes or into the bucket, so nodes with different settings, or
of versions with and without the wait, work together.
What the wait does not cover, with the defaults:
- The requests that find the death out. A request already sent to the node when it died gets the 503 (“did not answer”), or a 504 after 30 s if the host went silent instead of refusing; it may have run. On each surviving node, a request that arrives while the dead node is still in view is sent to it, and gets that 503 when the connection is refused. Only requests after it are kept. If the host is silent rather than gone (a cut cable, a frozen machine), connections to it are not refused but time out after 5 s: every request that arrives before the first of them timed out waits those 5 s for its 503, and one written into a connection that was already open gets a 504 after 30 s.
- A request that arrives in the first seconds can still get the 503. The takeover can come up to about 21 s after the node’s last write, the surviving node may learn of the fence a heartbeat period later, and after a bucket outage the watch before a fence starts over: a request kept from the very moment of the death can reach its 25 s and be answered 503 after waiting that long. One that arrives once the node left the view waits about 15 s at most.
- Another node took the actor first. The kept request is then forwarded to it, and waits for that node’s restore of the actor under the usual 30 s limit: about 55 s in the worst case, ending in the usual 504.
- An owner that is alive but refuses connections. When this node
cannot open a connection to a node that is alive (its listener
restarts, a firewall or a one-way network fault, the 5 s connection
timeout, or this node running out of sockets), the request that finds
it out gets the 503 as before, and the
GETs and upgrades that follow for that node’s actors are kept instead of sent, even over connections to it that still work. They are released when a heartbeat listing shows a lease write of that node more than 2 s (twice the allowed clock skew) after the failure: about 2 to 8 s later, since a node writes its lease every 3 s and this node lists every 3 s. They are then sent to the owner as usual, and get its answer or the 503 they would have got at once. Nobody takes the actors. The node is then not suspected again for 10 s; while the fault lasts, the next failed connection after that starts the same again. - The answer after the wait can still be an error: the node is full, the bucket fails during the takeover, or an access rule refuses the request (a class open only to existing actors answers 404 for one that does not exist, after the wait; a class not reachable from outside is refused 403 at once, before any wait).
- A node that drains takes no actor: a request it would hand to a peer is handed over as before, not kept.
- A
GETthat writes. A keptGETruns up to 25 s after it was sent, and its client may have retried elsewhere meanwhile. An actor whoseGEThandlers change state should not rely on the wait: turn it off, or make those handlers safe to repeat. - A load balancer that hides the client’s departure. A kept request is dropped when its connection closes. A proxy that keeps its connection to the node open after its own client gave up hides that, and the request then runs for nobody: see the front door for the settings that matter.
wasmkeep_failover_waits_total counts the kept requests and how each
wait ended (monitoring).
Replication
Each actor’s database follows it from node to node (ADR 0007):
- After every event, the owner uploads what the event committed as an LTX
file under
actors/<tenant>/<class>/<name>/wal/, and a snapshot every 64 uploads. A node that takes an actor over rebuilds its database from the bucket first, unless its local copy is already current. - An upload the bucket leaves unanswered is given up and sent again: an attempt after 5 s plus a second per megabyte (the next one after twice that), three attempts at most, the whole upload within 20 s plus that second per megabyte. This holds for a write’s file, for an actor’s first snapshot and for the snapshot after a delete. An attempt is one call to the bucket client, which sends the request again within it when the bucket answers with an error (a 5xx, 429 or 408) or the request could not be sent. The client also ends any single request after 30 s by default, and does not send an upload again after that or after a broken connection: the attempt fails. So a request lasts 30 s at most whatever the object’s size, and an attempt as long as the bounds above allow: the second per megabyte gives a first attempt’s request more time up to 25 MB and no further, and an object the bucket cannot take within 30 s in one request is not uploaded.
- A periodic snapshot is an optimisation, and the actor is held while it
uploads, so it gets one attempt, which the node does not send again
(the client may, within the attempt, after an error answer): given up
after 5 s plus a second per megabyte, 10 s at most (6 s under a
megabyte), a third of
the 30 s a queued caller waits for an actor (the default wall-clock
limit, for
actors.call, and the wait for a forwarded request’s answer). One that fails costs no write and fails no event (the files already uploaded are the history). It is tried again once the actor has made one more write and 30 s have passed, then, while it keeps failing, 8 writes later, then every 64. With a bucket that leaves every snapshot upload hanging, the worst case is 10 s of hold per try and three tries in any 64 writes (30 s), never two within 30 s, then one try per 64 writes (10 s); an actor that only reads does not try again. The node remembers these waits while an idle actor is out of its memory, so waking it does not try again either; a node that takes the actor over from another, or one that restarted, knows of no wait and tries at the actor’s first event there when 64 writes or more have no snapshot. A database the bucket cannot take within those 10 s gets no periodic snapshot: each try is one bounded attempt, and a restore replays every write since the last snapshot. Uploads not acknowledged are counted bywasmkeep_snapshot_upload_failures_total(monitoring). storage.execcommits locally; its upload follows when the event ends. If the node dies in between, that write can be lost.storage.exec-durablereturns only once the write is in the bucket, uploaded while the node still held the actor’s lease: an acknowledged durable write survives any node failure. The bucket is its only durable copy: the node does not fsync it locally as well, and trusts its local file again only after checking it against the bucket. So start a node again on the same bucket and prefix: after a crash or a power loss its local files may lack writes the bucket has.- If an upload fails, the node gives the actor up; the next request restores it from the bucket, possibly on another node.
- A restore’s reads are bounded together. The node lists the actor’s history and
reads its last snapshot and the files after it, eight at a time; every
request waiting for the actor waits behind that. A read the bucket leaves
unanswered for 5 s, or that is slower than 5 s plus a second per
megabyte, is sent again, and so is one the bucket refuses with an error
a resend can get past (a 5xx or a broken connection, after the bucket
client’s own retries), half a second later, then a second: three tries
per file. While it pauses, a read gives up its place among the node’s
reads, and waits for one again before it is resent. The reads of one restore have 30 s together, plus a second
per megabyte of what it reads and a second per eight files: 39 s for 63
small files and a snapshot under a megabyte, about 17 minutes more for a
database of 1 GiB. That counts the time the restore waits for the
bucket to give it the next file. It does not count the listing (given
up only when the bucket sends no key for 5 s, so a long history lists
for as long as its pages take), nor work on the local disk (never
interrupted), nor the time a read waits for its turn among the node’s
other restores (they share 32 reads and 256 MiB at once): while that
queue lasts, a caller can wait longer than the deadline. Nor does it
count what other restores add to a read’s time. A read’s own limit
counts every megabyte being read on the node at that moment, other
restores’ included, since they share one link: after a node dies, the
node that takes over 32 actors of 8 MiB each gives every read 261 s
(5 s plus 256), while each restore, by its own size, has 39 s. So for
each file, the seconds that other restores’ megabytes added to the
limits of its read’s tries are left out of the count, up to the time
the restore waited for that file. A restore is therefore never ended
because of what other restores are reading: what is counted for a file
is at most what its read is allowed with nothing else read on the node.
A restore alone on the node gets nothing more than its 30 s and its own
seconds, and slow reads that add up end it. At worst a restore’s waits
for the bucket last its own time plus 768 s per file (three tries, each
given at most 256 s by the 256 MiB the node reads at once): only while
other restores keep the node at that limit and each of its reads is
tried three times to the end of its time, which is what each read’s
own limit allows. A bucket that never answers ends a restore sooner,
whatever else is read: a read is given up after three silences of 5 s.
A restore that fails or runs out of time before its
database is complete changes nothing on the node: the database is built
beside the local file and put in its place only when complete. If
putting it in place fails (an error of the local disk), the file that
was there has already been moved to
<name>.db.replaced, and the node is left with no file for the actor or with the new one unmarked. Either way the next restore starts again from a fresh listing and reads the bucket: a node uses a local file as it is only after checking it against the bucket’s history. The request that waited gets a 503 withRetry-After, as for a bucket that does not answer (a history that cannot be restored without losing data stays an error, 500), the node gives the actor up so another may try, andwasmkeep_restore_failures_totalcounts it (monitoring). storage.delete-selfis recorded in the bucket, so a deleted actor stays deleted wherever it runs next. Like a plainexec, it is replicated when the event ends: if replication fails at that moment, the delete can be lost. Once the delete is recorded, the actor’s history in the bucket is erased (see History in the bucket).- Inside a transaction,
exec-durabletakes only theCOMMIT: it returns once the whole transaction is in the bucket. Any other statement there is refused before it runs, since nothing is durable beforeCOMMIT. - Pending alarms are listed in the bucket (
alarms/, by due time). Every node reads the overdue part of that list every 10 s and fires alarms overdue by more than a lease period plus grace, so an alarm whose node died still fires (ADR 0009). - When a node rebuilds a database from the bucket, the local file it
replaces is kept as
<name>.db.replacedrather than deleted. Only one generation is kept: a second replacement of the same actor’s file overwrites the first.replacedcopy.
Joining a cluster with existing data: the first node to touch an actor
decides its history when the bucket is empty. Start the node that holds the
existing databases first, and let it serve each actor once, before adding
nodes. A database replaced by mistake can be recovered from .replaced,
until a later replacement of that actor’s file overwrites it, so copy it
elsewhere before then.
Try it locally
Any S3-compatible store with conditional writes works. Each node proves
it at start, before it writes or takes anything: on a throwaway object
under probe/, a create over an existing object and a replace with a
stale version must both be refused. A store that accepts them (it
ignores the conditions) would let two nodes own one actor, so the node
logs why, stays not ready and wasmkeep serve exits. A bucket that does
not answer yet is tried again, as at any start. Cloudflare R2 and
OVHcloud Object Storage pass it (tested); AWS S3 and MinIO document both
conditions. For a quick local try,
moto’s S3 server is enough (it keeps
everything in memory):
pip install "moto[server]" && moto_server -p 5055 &
curl -X PUT -H "Content-Length: 0" http://127.0.0.1:5055/wasmkeep-demo
export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test \
AWS_REGION=us-east-1 AWS_ENDPOINT=http://127.0.0.1:5055 AWS_ALLOW_HTTP=true
W=examples/counter/target/wasm32-wasip2/release/counter.wasm
wasmkeep serve --actor counter=$W --data-dir .wasmkeep/a --listen 127.0.0.1:8081 \
--node-id a --advertise http://127.0.0.1:8081 --bucket s3://wasmkeep-demo \
--cluster-listen 127.0.0.1:7481 --cluster-advertise https://127.0.0.1:7481 &
wasmkeep serve --actor counter=$W --data-dir .wasmkeep/b --listen 127.0.0.1:8082 \
--node-id b --advertise http://127.0.0.1:8082 --bucket s3://wasmkeep-demo \
--cluster-listen 127.0.0.1:7482 --cluster-advertise https://127.0.0.1:7482 &
curl -i -X POST localhost:8081/actor/counter/alice # 1, x-wasmkeep-node: a
curl -i -X POST localhost:8082/actor/counter/alice # 2, forwarded to a
Flags
| Flag | Meaning |
|---|---|
--node-id | This node’s id, [a-z0-9-], unique in the cluster (see below) |
--advertise | URL the other nodes use to reach this one |
--bucket | s3://BUCKET[/PREFIX]; several clusters can share a bucket under different prefixes |
--cluster-listen | Address of the cluster listener, e.g. 0.0.0.0:7443: the port other nodes use, with mutual TLS (see below). Needed unless --cluster-plaintext accept; give it with --cluster-advertise |
--cluster-advertise | https://HOST:PORT the other nodes reach the cluster listener at |
--cluster-plaintext | refuse (default) or accept: whether plain HTTP between nodes is still used (see below). With refuse, a node needs --cluster-listen |
--failover-wait-secs | How long a GET or HEAD without a body, or a WebSocket upgrade, for an actor whose node died or is stopping is kept until another node took the actor over, instead of a 503 at once; default 25 (from the lease timing), 0 turns it off (see “When a node dies: what a caller sees”) |
--history-retention-secs | How long old actor history stays in the bucket before garbage collection may delete it; default 3600, 0 allowed (see “History in the bucket”) |
--secrets-key FILE | With --app: the key the app’s secrets are sealed with (wasmkeep secrets new-key); given twice during a key change (see “Secrets in the bucket”) |
--no-secrets | With --app: run without the app’s secrets from the bucket (--env only); without it and without a key, a node refuses to start once the app has secrets |
Node ids must be unique. A node may take back its own lease at once
(that is how a restarted node recovers its actors quickly), so two processes
sharing an id would be treated as one node and could both run the same
actor. A node therefore checks its id when it starts, before its first
heartbeat and before any lease. It reads nodes/<id> every half period for
the lease’s ttl plus grace (15 s by default), the time any other
node waits before taking a dead node’s actors, even when there is no such
object (a process whose object was deleted while it ran is given time to
show itself). A process still running rewrites it: the new one then exits
with an error naming the other’s address, having taken no actor. A peer’s
fence or the old process’s own release of it does not count as running:
that process lapsed once the object’s last live write is those 15 s old,
and the new one goes on then. A heartbeat in the format from before node
leases (ADR 0020) keeps the new one
waiting until it is older than its 10 s horizon plus those 15 s, since such
a process renews its per-actor leases on a task of its own. Unchanged until
the end: the previous process is gone, and the new one writes its first
heartbeat, conditioned on the version it read last. So:
- A first start, or a restart while the previous heartbeat is still live,
waits those 15 s before
wasmkeep servelistens (it logs “checking that no other process runs with this node id”). After a clean stop or a fence of the previous process it waits only until that process’s last live write is 15 s old: about 1.5 s when that has already passed. - A restart while the old process still runs, or still finishes its graceful shutdown, is refused: let the service manager retry it.
- If the bucket cannot be read, the node keeps retrying and does not start serving.
- Two processes started within a moment of each other can both pass the
check, but only one of their first writes can land: each is conditioned
on the object the check read last. The other process’s write fails; if
it was already running, its next write fails the same way. That one
drops its leases at once, takes no new lease, stops heartbeating, and
wasmkeep serveshuts down and exits with an error; the other goes on. The listing of the heartbeats still catches a heartbeat with another token, or none (a node of an older version, which writes without a condition). Until then both may run the same actor, so still start a node only once the previous process with its id has stopped. - Deleting a running node’s heartbeat makes that node exit at its next write, for the same reason.
wasmkeep serveexits with an error when its node id is refused at start too. A service manager that restarts it at once (Restart=always, a Kubernetes rollout bringing a new pod up before the old one stops, both with one fixed--node-id) retries until the other process has stopped.
When replacing a node, stop the old process before starting the new one with the same id.
WebSockets. A client may open a socket on any node: a node that does
not own the actor relays the upgrade to the owner and joins the two
connections, so everyone in a room shares one actor wherever they connected.
If the actor later moves, its sockets close with code 1012; clients
reconnect. A room that hibernates keeps its sockets open on its node but
releases its ownership record, so a request that reaches it through
another node wakes it there; its sockets on the first node then close with
1012 when one of them next sends a message, or at the node’s owner sweep
(once a minute), so a client that only listens is closed too, a minute
later at most. Sockets also close with 1012 when their node drains and
another node can take its actors (see “Draining a node” below), and when
its lease runs out (no write reached
the bucket for ttl minus safety, 8 s with the defaults): before any other
node can take its actors, and without waiting for the bucket to answer
again. A socket closed because its node lost the actor gets no
on-websocket-close there; a room keeping a list of who is in it should
rebuild it from connections(). Such a close can reach every client of
a node at once (a lease that runs out closes them all): clients should
reconnect after a random delay on 1012, backing off on each failure, so
they do not all reconnect in the same instant. A client that does not
answer a close within 5 s is dropped. If the owner does not answer the
upgrade, the client gets a
503 and may open the socket again; over mutual TLS between nodes, that 503
can take up to four heartbeat periods (12 s with the defaults) when the
owner refuses the relaying node’s certificate (see “A caller its peer
briefly stopped trusting” below).
Calls between nodes. actors.call reaches an actor wherever it runs.
Nodes prove to each other that a call comes from the cluster with a
signature: HMAC-SHA256, keyed with a random secret the first node stores at
cluster/secret in the bucket, over the node the call is for, the method,
path, body, calling actor, chain of waiting actors, a timestamp and a nonce
unique to each send (valid for a minute, and accepted once). Only a signed call’s
caller (x-wasmkeep-caller) and chain are believed, so a client cannot pass
for an actor or reach an internal class, and a call cycle across nodes is
refused instead of deadlocking. The secret itself never travels; a malformed
one in the bucket stops calls between nodes rather than weakening them.
Credentials, region and endpoint come from the standard AWS_* environment
variables.
Encryption between nodes. Nodes talk to each other over mutual TLS,
on a cluster listener every node needs: a node started with --node-id
and without one refuses to start, unless given --cluster-plaintext accept. In that mode, nodes without a listener talk over plain HTTP: a
call between actors is signed, its body included, but nothing is
encrypted and responses are not signed. Keep such nodes on a private
network (a node warns at start when --advertise is a public address).
A cluster listener on every node:
wasmkeep serve ... --node-id a --advertise http://10.0.0.11:8080 \
--cluster-listen 0.0.0.0:7443 --cluster-advertise https://10.0.0.11:7443
Forwarded requests, relayed WebSockets and actors.call between two nodes
that both have one then travel over mutual TLS 1.3 on that port, in both
directions. There is nothing to provision: each node makes a key and a
self-signed certificate in memory at start (a restart is a new key) and
publishes the certificate’s fingerprint in its heartbeat; a node accepts a
peer only if the peer’s certificate matches a live heartbeat in the bucket
(ADR 0011). So whoever can write the
bucket controls the cluster, as with the call secret.
-
Both flags go together, and only with
--node-id;--cluster-advertisemust behttps://. The cluster port serves actor paths and the health and readiness checks, nothing else, and only to other nodes. -
Firewall: only the other nodes need to reach the cluster port; do not expose it to clients or put it behind the load balancer.
--listenstill serves clients as before. -
A node with a listener reports not ready (
/_wasmkeep/ready) for a few seconds after start, until its fingerprint has been published long enough for peers to have seen it. -
Rolling it out on a cluster that ran in
acceptmode: restart nodes one at a time with the flags, keeping--cluster-plaintext accept. Nodes reach peers that have a listener over TLS and the others over plain HTTP, and still accept plain signed calls on--listen, so a mixed cluster keeps working. A peer that publishes a TLS address is reached over TLS or not at all (a 503 the caller retries), never in plain text after a TLS failure. Until every node refuses plain HTTP (below), keep the public port’s node traffic private too. -
Refusing plain HTTP (
--cluster-plaintext refuse, the default): a node’s call then counts only on the cluster port. A signed call arriving on--listenis treated as a client’s (access rules apply, no calling actor,internalclasses refused), and peers without a cluster listener are not used: requests and WebSockets for actors they own get a 503 from this node andactors.callto them fails (it logs which peers it skips, once a minute). Clients are served on--listenas before. A refusing node next to plain peers still reports ready (/_wasmkeep/ready): the load balancer will not take it out, so do not get into that state (below). -
Moving a cluster from accept to refuse: first give every node a cluster listener (restart them one at a time with the two flags and
--cluster-plaintext accept, and check withwasmkeep cluster statusthat every live node shows a cluster address and a pin, and no warning about plain nodes: no node may still be plain). Then restart them one at a time again, without--cluster-plaintext accept. Doing it the other way round breaks the cluster while it is mixed: a refusing node cannot reach a plain node’s actors (503), and a plain node’s calls to it count as a client’s (internalclasses refuse them, callees see no caller). Going back is the reverse: addaccepteverywhere before removing a listener. -
The cluster port has its own connection cap (1,024, less when the open-files limit is low; its share is counted before the client connections and resident actors are sized).
-
What it costs: on a pooled connection, TLS adds about 9 to 12 µs to a forwarded request and 15 to 21 µs to an
actors.callbetween nodes, on top of a hop of about 40 µs over loopback (measured). Connections between nodes are kept alive, so full handshakes are rare. -
How fast a removed node loses access: revoke a node’s process with
wasmkeep cluster fence <node> --bucket ...(ADR 0020): it stops at its next heartbeat write, and its actors are free 15 s after its last write. A service manager that restarts it brings it back as a new process; to remove a machine, stop and disable its service too, and rotate its bucket credentials (security, “Revocation”). A node stops trusting a peer whose heartbeat is fenced, released, deleted or names a new key at its next listing of the heartbeats (one per heartbeat, 3 s by default), and one whose heartbeat stops at its expiry: three heartbeat periods plus the allowed clock skew, 10 s with the defaults, also when the node cannot list the bucket. Relayed WebSockets are cut within 500 ms after that (security, “Revocation”; timed incrates/wasmkeep-server/tests/revocation.rs). -
A caller its peer briefly stopped trusting: if a peer refuses a node’s certificate at the handshake (it saw that node’s heartbeat expire, as a slow bucket write can make it), an
actors.callis sent once more after the caller’s next heartbeat reached the bucket and two more periods passed (12 s at most with the defaults; it is not sent again if that heartbeat does not land within two periods); the peer never read the first one. Then it fails with “refused this node’s certificate”. A relayed WebSocket upgrade, which has no body, is sent once more the same way, the client waiting meanwhile; if it is refused again, or the heartbeat does not land, the client gets a 503 asking it to retry. A forwarded request gets that 503 at once: its body is not kept. A call or an upgrade that came back unsent on a new TLS connection to the peer is treated as one refused at the handshake (a refusal can close the connection before the request is written; whatever closed it, the peer read nothing). Nothing else is sent again: not a request whose connection could not be opened, and not one written into a connection that then failed, which the peer may have read.The price is the client’s wait when the peer keeps refusing. The upgrade’s 503 then comes after up to four heartbeat periods if the heartbeat lands and the second try is refused too (12 s at most with the default 3 s period, 9 s or less when the next heartbeat lands on time: up to one period for it, then two), and after two periods (6 s) if the heartbeat does not land; a call waits as long for its failure. Answering at once would be faster in that case only: a peer that refuses a live node does so for a moment, and then the wait ends in the open socket or the call’s answer, where a 503 at once would leave the client to open the socket again and fail the call. A client that gives up on a WebSocket handshake sooner than that sees its own timeout instead.
An upgrade of WasmKeep itself, including one that changes the format of signed calls, upgrades every node together (below).
Deploying
Instead of giving every node --actor CLASS=PATH, deploy once to the bucket
and let nodes follow:
wasmkeep deploy --app acme/shop --bucket s3://wasmkeep-demo \
--actor counter=counter.wasm --actor chat=chat_room.wasm
wasmkeep serve --app acme/shop --bucket s3://wasmkeep-demo --node-id a ...
wasmkeep deploy --app acme/shop --bucket s3://wasmkeep-demo --rollback
-
One app per tenant per bucket prefix, until each app owns its actors’ state (planned for 0.4.0, ADR 0021). Actors are keyed by tenant, class and name, so two apps of one tenant on the same prefix would share actors. The first
deployof an app refuses when another app of its tenant is deployed on the prefix (it hasapps/<tenant>/<app>/current), naming that app: deploy the new one on another prefix (s3://bucket/admin), or under another tenant if another owner runs it (--app globex/admin). Another prefix is a separate cluster with its own cluster secret: the app starts there with no data, its secrets are set again, and it cannot be moved to another prefix later (its secrets are sealed to the prefix). A node also refuses to start when a live node on its prefix serves another app of its tenant. Two apps of one tenant deployed on one prefix before this check are left as they were (in a cluster, still not running at the same time), anddeployandservewarn about them by name; a further one is refused while any of them has acurrent. The first deploy needs list access to<prefix>apps/<tenant>/(a listing that fails refuses); two first deploys at the same moment can both pass it. There is noapp deleteyet. To free the tenant from an app nobody runs, stop its nodes and delete itscurrent, which keeps its versions and secrets:aws s3 rm s3://wasmkeep-demo/apps/acme/legacy/current # or remove the app entirely (its versions, current and secrets): aws s3 rm --recursive s3://wasmkeep-demo/apps/acme/legacy/Its actors’ data stays under
actors/<tenant>/,owners/<tenant>/,leases/<tenant>/andalarms/<tenant>/, and the next app of the tenant finds the actors of any class with the same name. -
The tenant and app names of a new app match
[a-z][a-z0-9-]{0,63}; an app deployed earlier keeps its name. -
deploycompiles and checks every component before uploading anything; a broken one is refused and the current version keeps running. -
Nodes check for a new version every 3 s and switch each actor at its next event. No actor is dropped;
on-initruns once per version. -
An actor never runs older code than it already ran: a node that has not caught up yet refuses it (the client retries) and updates at once.
-
Rollback makes the previous version current again (as a new deploy), with its access rules and configuration.
-
Old versions are removed by
wasmkeep deployitself, after every deploy and rollback (ADR 0012): it keeps the current version and the five before it (as many rollbacks as you can make in a row), and deletes older ones once they stopped being current more than--version-retention-secsago (default 3600; 0 at once; measured with the bucket’s timestamps against the clock of the machine running the command). Only versions in the current one’s line ofpreviouslinks are candidates, oldest first, and the pass stops ifcurrentchanges while it runs. Never collected: a version a concurrent deploy is still writing, one a failed deploy left, the versions a rollback stepped back from, and a version whose manifest this WasmKeep cannot read. Those stay in the bucket (they are small).
Updating the stateless component
A deploy carries actor classes, with their access rules, configuration
and required names. It does not carry the stateless component, the
positional argument of wasmkeep serve that answers every path outside
/actor/ and /_wasmkeep/: that one is a file on each node, read once
when the node starts, also with --app:
wasmkeep serve site.wasm --app acme/shop --bucket s3://wasmkeep-demo --node-id a ...
Nothing in the bucket names it, so wasmkeep deploy does not change it,
--rollback does not bring an older one back, and cluster status does
not show which one a node runs. To update it today:
- Copy the new file to every node, at the path its
servecommand names. - Restart the nodes one at a time (
systemctl restart wasmkeep), waiting for each to be listed live bywasmkeep cluster statusand to answer/_wasmkeep/readybefore the next.
What happens meanwhile:
- The restarting node makes a clean stop: requests under way get up
to 10 s to finish, its actors are given up and start on the other nodes
at their next request, and its WebSockets close with 1012. The load
balancer’s check of
/_wasmkeep/readytakes it out. At its start it checks its node id first, which takes what is left of 15 s since it stopped (Flags). - The other nodes keep answering, each with the file it was started with. Until the last node has restarted, two versions of the component answer side by side, and a client may get either one from one request to the next. Keep two consecutive versions compatible with each other and with the deployed actors.
- A file that does not compile or instantiate makes
serveexit at start. Restarting one node first shows it while the others still run the previous file. - Going back is the same procedure with the previous file.
The stateless component has no actors.call: it is linked against WASI
and wasi:http only. It reaches actors as any client does, over HTTP,
under the same outbound rules as actors (public addresses only, unless
the node allows a range with --allow-outbound).
Access rules and configuration
Access rules (ADR 0010) and plain configuration are part of the deployed version (ADR 0013): every node applies the same ones, at the same moment as the code they go with, and a node started without flags no longer serves a restricted class open.
wasmkeep deploy --app acme/shop --bucket s3://wasmkeep-demo \
--access monitor=existing --access hub=existing:main \
--config hub=hub-config.env
-
A deploy changes what it names and keeps the rest. Without
--actor, the current classes are kept (copied inside the bucket, not uploaded again). Classes not named keep their rule and configuration, so a CI job deploying new code does not drop rules it never knew about.--access CLASS=openremoves a rule,--config CLASS=a class’s configuration.deployprints what changed (class code, rules, the names of configuration values) and warns when a class removed with a rule leaves an added one open (a rename). -
--configis stored in the clear, as parsed (no comments). Keep secrets inserve --envfiles on each node: they win over the deployed configuration by name (each overlap is logged by name, never value).deploywarns about names that look like secrets (TOKEN,KEY,PASSWORD…) and URLs with a password, without printing them. -
Rollback brings back the previous version’s rules too. One that would make a class more open (
existingtoopen, say) is refused unless--allow-reopenis given. -
New values drop the class’s resident instances at their next event (the database is kept,
on-initdoes not rerun); rules alone do not. A class madeinternalcloses its open sockets (1008) at their next message. -
A node that reads a version it cannot apply keeps the previous one and reports not ready (
/_wasmkeep/ready) until it can. -
--accesswith--appis refused once rules have been deployed (the start-up error gives thewasmkeep deploycommand). Before that it still works, so a cluster started with--accessmoves in three steps:- upgrade every node, flags unchanged;
- deploy the rules once:
wasmkeep deploy --app acme/shop --bucket ... --access monitor=existing(nodes switch to them and log that--accessis now ignored); - remove
--accessfrom the nodes’ command lines.
A node following an app that never deployed rules, started without
--access, logs that every class is open.
Secrets in the bucket
Secrets can live in the bucket, sealed with a key the nodes hold, and change without a deploy or a restart (ADR 0016):
wasmkeep secrets new-key --out secrets.key # mode 0600; keep it with the bucket credentials
wasmkeep serve ... --app acme/shop --secrets-key secrets.key # every node
wasmkeep secrets set --bucket s3://acme-wasmkeep/prod --app acme/shop --key secrets.key \
--class hub ADMIN_TOKEN < token.txt # one trailing newline removed
wasmkeep secrets list --bucket s3://acme-wasmkeep/prod --app acme/shop --key secrets.key # names only
- Reaching the nodes. Each node reads the app’s secrets at start and
checks for a change every 3 s. A class whose values changed drops its
resident instances at their next event (the database is kept,
on-initdoes not rerun); an event under way keeps the values it started with. - Which value wins, by name: the deployed configuration, then the
secrets, then the node’s
--env(logged at warn, never the value).--envstays a node-local break-glass. - Several at once:
set --file FILE(entries<class> <name> <length>\n<value>\n, any value, newlines included) andunset --class CLASS NAME...make one change each, so nodes never serve half of it. - A node that cannot open the current secrets refuses the app’s
events with 503 and
Retry-After, hands its actors to a node that can, takes none, and reports not ready. It blocks at once on a set it cannot open (no key, another key, an altered object), and after 30 s on one it cannot reach (the object missing or unreadable,currentunreadable). Past those windows (one poll, 3 s, or the 30 s) it never runs an actor with an older or empty set.cluster statusthen shows it draining or drained without a drain marker;undraindoes not apply. When no node can open them, every event for the app is 503 until one can. Socket close events on a blocked node do not run. Without--secrets-key(and without--no-secrets), a node refuses to start once the app has secrets. - Restoring the bucket from a backup can put
currentbelow what the running nodes opened; they refuse it (and block after 30 s) until restarted: restart the nodes after a restore (Back up and restore a cluster’s bucket has the whole procedure). Deletingcurrentby hand leaves running nodes with the set they have and restarted ones with none. - Older nodes.
set,unsetandresetrefuse, without--force, while a live node of the app does not read secrets (an older version, or one run with--no-secrets): it would serve them as unset. Nodes say so on heartbeat line 10; the check counts a heartbeat as live with the default timings, ascluster statusdoes. - Changing the key. Give every node the new key as well (a second
--secrets-key, one node restarted at a time), runwasmkeep secrets rotate-key --key old.key --new-key new.key --app acme/shop --nodes-have-new-key(or--all), then remove the old key from the nodes. The flag is required: a node without the new key cannot open the re-sealed set and answers 503 for the app until it gets the key, and no heartbeat says which keys a node holds.setandunsetgiven two keys seal with the first, and take the same flag when that one is not the key the current set is sealed with. After a key compromise, change the values too: the old key opens the objects it sealed. - Losing the key. Give every node the new key, then
wasmkeep secrets reset --new-key new.key --from FILE --nodes-have-new-keywrites a whole set without opening the current one. - Moving from
--env. Upgrade every node, give them the key,seteach name, then remove the names from the--envfiles and restart the nodes one at a time. - Required values.
wasmkeep deploy --requires hub=ADMIN_TOKEN,WEBHOOK_URLmakes those names part of the version: while one is not set (by the configuration, the secrets or a node’s--env), the class refuses its requests with 503 (an alarm waits, a socket closes with 1011) instead of running without it, and the node logs the class and the missing names once (again if they go missing again), never a value. A value set to the empty string counts as set. Once the value is set, requests run at once; a waiting alarm runs within the alarm retry ceiling (5 minutes by default); an idle socket closes at its next message. Kept by later deploys;--requires hub=removes them, and a rollback brings back the previous version’s. Required names alone do not end ADR 0013’s migration (only rules or configuration do).
Checking a cluster
wasmkeep cluster status reads the bucket alone, so it works from any
machine with read access to the bucket, whether or not any node answers:
wasmkeep cluster status --bucket s3://wasmkeep-demo
wasmkeep cluster status --bucket s3://wasmkeep-demo --json # for scripts
Cluster s3://wasmkeep-demo (live: heartbeat within 10.0s)
NODE LIVE AGE STATE VERSION APP ADDRESS CLUSTER ADDRESS TLS
a yes 979ms serving 0.3.0 acme/shop http://a:8080 https://a:7443 pinned
b yes 2.2s serving 0.3.0 acme/shop http://b:8080 - plain
c no 14m serving 0.3.0 acme/shop http://c:8080 https://c:7443 pinned
APP DEPLOYED VERSION SEQ
acme/shop 9cf4de9152d8dd29 1
2 of 3 nodes live; TLS: 1 with a cluster listener, 1 plain; version: 0.3.0
warning: mixed plain and TLS nodes: b still plain while a use the cluster listener; give every node a cluster listener, then drop --cluster-plaintext accept (ADR 0011)
- Nodes come from their heartbeats (
nodes/<id>): the WasmKeep version each runs, the app it serves, its advertised and cluster addresses, and whether it publishes a TLS pin. State is whether it takes actors:serving, or, while it is drained (ADR 0014),draining,drained(no actors or WebSockets left) orstuck(actors or WebSockets left and no node of the same app to hand them to);-from a node that predates draining. A node is live if its heartbeat was written within 10 s (three heartbeats plus the allowed clock skew), the rule the nodes themselves use with the default settings. Ages are measured on the clock of the machine running the command, so keep it in sync too. A heartbeat writtenfenced(a peer took its lease) orreleased(a clean stop) is shown asno (fenced)orno (released)whatever its age, and asleasein--json. A stopped or crashed node stays listed, not live, until its heartbeat object is removed from the bucket. - Apps come from
apps/<tenant>/<app>/current: the deployed version and itsseq. Which version each node has picked up is not in the bucket (nodes follow within a few seconds). - Warnings: no live node; live nodes still plain while others use the
cluster listener (the state ADR 0011
says not to stay in, and not to refuse plain HTTP from); live nodes on
different WasmKeep versions (see Upgrading WasmKeep);
a draining node that is stuck; a node lease dated ahead of this clock by
more than the allowed skew (
ahead_msin--json). Such a lease never goes stale, so no node fences it and the actors it holds stay held until that date: fix the clock that is wrong (that node’s, or this machine’s if it is behind).wasmkeep cluster fencestops the node, but the fence keeps the date, so its actors free 15 s after it; a fenced or released node whose date is still ahead is warned about too, until then. The nodes log the same, at most once a minute. - The exit code is 1 when no node is live (an empty prefix included), 0 otherwise, warnings or not; a bucket it cannot read is an error.
Draining a node
To take a node out of service without stopping it first (to replace the machine, resize it, or upgrade it), drain it:
wasmkeep cluster drain node-b --bucket s3://wasmkeep-demo
wasmkeep cluster status --bucket s3://wasmkeep-demo # wait for STATE drained
While it drains, the node (ADR 0014):
- claims no actor. A request, a call or a WebSocket upgrade for an actor it does not hold goes to another live node serving the same app, which takes it;
- hands over the actors it holds, up to 16 at a time:
- their WebSockets close with 1012 (clients reconnect and land on the new owner);
- their queued events finish and are uploaded;
- the lease is released, and each pending alarm moves to the new owner’s timer, so it fires on time even after this node stops;
- closes the WebSockets of rooms that hibernated with sockets open
with 1012. Their clients reconnect through another node; their
on-websocket-closeevents do not run, since the draining node does not take the room back to run them; - reports
drainedonce it holds nothing, no WebSocket included, and keeps running and forwarding until you stop it. You can then stop it with nothing left to hand over. A WebSocket this node relays to another node is not counted, and is cut when the node stops.
A node with no other node to hand to (alone, or every other node draining
or older than this feature) keeps serving its actors and reports stuck.
cluster status warns about it. Start another node, or undrain one.
wasmkeep cluster undrain node-b --bucket s3://wasmkeep-demo # take actors again
Both refuse an id that no live node has (as cluster status counts it): a
drain request for a mistyped id would stay in the bucket until an undrain
with the same typo. undrain still withdraws a request no node answers to.
--force writes or deletes the request anyway (for a node about to start).
During each actor’s hand-over its requests may get a 503 with
Retry-After: 1 for a moment. A peer that fails to take an event is
skipped for about 10 s. A request that hits a dead peer before that gets
its one error.
Moving one actor
wasmkeep actor move acme/room/lobby --to node-c --bucket s3://acme-wasmkeep/prod
The target must be a live node: an id no live node has is refused without
--force, and a target that is draining, or too old to take moved actors,
gets a warning (the request is written; the owner keeps the actor), as
does one that serves another tenant.
Within a few seconds the actor’s owner hands it to node-c
(ADR 0017):
- its WebSockets close with 1012 and their close events run;
- its queued events finish and are uploaded;
- the lease is released, and from that moment every event for it on the
old owner (requests, calls, alarms, reconnecting sockets) goes to
node-c; node-ctakes it: it acquires the lease, restores the actor and arms its alarm.
If no node holds the actor, node-c takes it itself. A name that was
never an actor is not created.
- The target must be live, serve the same app, not be draining or
blocked, and run a version that takes actors (heartbeat line 11,
take). Otherwise the request waits, tried on every heartbeat, until it expires. - Not a pin. Once idle, the actor hibernates on
node-c, and its next event claims it wherever that event lands. A draining node does not act on move requests; its drain moves everything. - Retries. A move that cannot finish (a socket that will not close, a target that does not answer) is tried again on every heartbeat until it works or the request expires (5 minutes).
Upgrading WasmKeep
Upgrading the wasmkeep binary itself (not your application: that is
Deploying, which rolls from node to node safely): stop
every node, upgrade them all, start them all. WasmKeep does not yet
support nodes of different versions in one cluster, even
for the length of a rolling restart. The cluster serves nothing between
the last stop and the first start, so plan a short window.
wasmkeep cluster status --bucket ...: note the nodes it lists as live.- Stop every node cleanly (
systemctl stop wasmkeep,SIGTERM). Each finishes its requests and releases its actors; a clean stop takes up to about 29 s. - Wait until
cluster statusexits with 1 (no live node), about 10 s after the last stop. A node still listed as live is still running the old binary: stop it first. This check does not depend on version strings. - Install the same new binary on every node.
- Start every node, then run
cluster statusagain: every node live, one version in the summary, no warning.
Going back to the previous binary is the same procedure. Builds made from
source report the version of the release they follow (0.3.0 for any
build since it), so the version column cannot tell two of them apart:
step 3 is what proves no old node is left.
What goes wrong if old and new nodes run together (each is a reason for the rule; there may be others nobody has listed):
- Calls between nodes: the format of signed calls can change between
versions.
Old and new nodes refuse each other’s calls (logged as “bad, expired or
misaddressed signature (… a node of an older version?)”), so calls to
internalclasses on the other kind fail, and a call cycle across them ends in a timeout instead ofreentrant. - Formats in the bucket: a version can change how heartbeats, ownership records or alarm index entries are written. A node of another version may skip what it cannot read, so it may not see its peers’ ownership or alarms: actors refused, alarms late or missed.
- No other guard: the deploy
seq(ADR 0008) keeps an actor from running older application code, not from an older WasmKeep. Nothing compares WasmKeep versions between nodes; the version in each heartbeat is only reported, bycluster status.
Rolling upgrades of WasmKeep across mixed versions are planned (ROADMAP).
History in the bucket
Every write an actor commits becomes an object in the bucket, and every 64
writes its owner adds a snapshot (tried again later when its upload fails,
see Replication). After such a snapshot, the owner deletes
the history it makes unnecessary: older writes and snapshots, and uploads
of earlier owners that no restore would use
(ADR 0012). It keeps what a
restore needs and anything younger than --history-retention-secs
(default 3600, one hour; 0 deletes as soon as the rule allows), so a bug
can be noticed before history is gone. The window compares the bucket’s
timestamps with the node’s clock: a node whose clock runs ahead shortens
it by that much (safety does not depend on it). Deleting an actor erases
its history right away, whatever the window: two small objects remain, so
it is still known to exist. If the bucket refuses, the erasure is retried
a few times while the node holds the actor; after that the history stays
until the actor writes again, counted by
wasmkeep_history_collect_failures_total. Collection runs in the
background, never delays a write, and stops if the node loses the actor’s
lease; deleted objects are counted by wasmkeep_history_collected_total
(monitoring). An actor that stops writing keeps fewer
than 64 writes (and anything that was younger than the window at its last
pass) until it writes again. Late uploads of earlier owners that land
after a pass stay until the next pass. Old deploy versions are removed
by wasmkeep deploy (see Deploying).