What is WasmKeep?
WasmKeep runs actors: small, long-lived, addressable programs that each own their state. You write an actor once (a class, compiled to a WebAssembly component) and WasmKeep runs as many instances of it as you have names.
/actor/chat/room-42 → the chat-room class, instance "room-42"
/actor/limit/api-key-7 → the rate-limiter class, instance "api-key-7"
The first request to a name creates the actor. There is no “create” call, no registry and no database to provision.
The guarantees
Each actor:
- Handles one event at a time, in arrival order. HTTP requests, WebSocket messages and alarms queue up in the actor’s mailbox. Inside an actor you never need a lock.
- Owns a private SQLite database. Nothing else can read or write it. Committed writes survive restarts.
- Hibernates when idle. Its code leaves memory after a timeout (30 s by default) and is brought back by the next event, in about a millisecond. Data, the pending alarm and open WebSockets all survive this.
- Is sandboxed and limited. It can only use what the
wasmkeep:actorinterface grants: its database, alarms, calls to other actors, WebSockets, outbound HTTP, clocks, randomness, and its configuration and secrets (theenvinterface). What it prints to stdout or stderr reaches the node’s log. Each event gets a CPU budget (50 ms by default) and each actor a memory cap (128 MiB per linear memory, at most two, by default).
Different actors run in parallel, so a server’s throughput comes from having many actors, not from making one actor busy.
What it is good for
Anything with many independent pieces of state, each touched by a modest stream of events (use cases compares this with stateless servers, Postgres and Valkey, and walks through each pattern):
| Use case | One actor per… | Uses |
|---|---|---|
| Chat, presence, collaborative editing | room or document | WebSockets, history in SQLite |
| Multiplayer game lobbies | match | WebSockets, alarms for timeouts |
| Rate limiting and quotas | API key or user | alarms to reset windows |
| Shopping carts, sessions, workflows | user or order | durable state, alarms for reminders |
| Per-user AI agents | user or conversation | durable memory, outbound HTTP |
| Device twins (IoT) | device | latest state, alarms for missed heartbeats |
| Customer plugins in a SaaS | customer or plugin | sandboxing, limits per actor |
When not to use it
- One big shared dataset (analytics, reporting, search across all users): use a real database. Actors are islands; querying across all of them is slow by design.
- A single very hot object: one actor runs on one core at a time. Split the state (one actor per room, not one per app).
- Stateless functions: plain
wasi:httpcomponents work in WasmKeep, but Spin or wasmCloud are built for that. - A global edge network: WasmKeep is self-hosted; it does not route users to the nearest of hundreds of locations.
- Production today: it is pre-alpha. Clustering works and has been soaked against R2 and OVHcloud Object Storage with nodes killed under load (measured), but has not yet carried real users’ traffic.
WasmKeep and Cloudflare Workers compares the two in more detail: where each is the better choice, and why.
Next: Concepts for how actors behave, or your first actor to try it.
How it works, briefly
- Runtime: Wasmtime, with a pooling allocator and ahead-of-time compiled, cached components.
- Interface: a WIT world on top of
the standard
wasi:http/proxy, so the request handler is ordinary WASI HTTP, plus WasmKeep’s storage, alarms, actor calls and WebSockets. - Languages: anything that builds a WASI 0.2 component. Rust, TypeScript, Python, C, Zig and Go (TinyGo) have SDKs and pass the same tests; D and MoonBit have experimental SDKs, and standard Go and C# each build a counter (see languages).
- Storage: SQLite in WAL mode, one file per actor, sandboxed (no
ATTACH, no extensions, no state-changing pragmas). - Clustering: each actor’s changes stream to an S3-compatible bucket; several nodes share the bucket and use it to decide, with conditional writes, which node owns each actor. If a node dies, another takes its actors over. No consensus cluster to run: if the bucket is up, WasmKeep is up.
Design details and the reasoning behind each decision are in
docs/adr; Concepts and Running a cluster
describe how it behaves, and
When a node dies, and how it scales says what
an application sees of it.
Who it is for, and real-world use cases
Most applications are well served by stateless servers in front of Postgres, with Valkey (or Redis) for caching, queues and pub/sub. WasmKeep does not replace that stack. It is for the parts of an application where that stack gets awkward: state that many requests fight over, connections that must meet in one place, timers that must fire exactly when they should, and code or data that must stay isolated per customer.
The short version:
- A good fit: many small, independent pieces of state (a room, a user, a device, an API key, a document), each receiving a modest stream of events, often with WebSockets or timers attached.
- Not a fit: one big shared dataset you query across (reporting, search, analytics), a single object every request touches, or plain stateless APIs. Details in the overview.
Next to Postgres, Valkey and stateless servers
| What you need | With stateless servers, Postgres and Valkey | With actors on WasmKeep |
|---|---|---|
| A chat room or game lobby over WebSockets | Sticky sessions, or Valkey pub/sub to reach whichever server holds each socket; presence kept somewhere shared | The room is one actor: its sockets, its state and the order of its events in one place. Sockets stay open while it sleeps |
| Exact counters, quotas, rate limits under contention | Row locks (SELECT ... FOR UPDATE), retries on conflicts, or Lua scripts in Valkey | One actor per key handles one event at a time: no race to write code for |
| Timers: reminders, retries, expiries, “check every minute” | A cron job, a jobs table, a queue, and a lock so two workers do not both run it | An alarm on the actor, delivered at least once, also when its node dies |
| Per-customer isolation (their data, their plugin code) | Row-level security or a schema per tenant, plus a separate sandbox for their code | A Wasm sandbox with CPU and memory limits, and a database per actor |
| Lots of small, independent state | One large shared database every request contends on, sized for the peak | Many small SQLite databases, each next to its code; an idle actor costs disk, not memory |
| Running it | A database to operate, back up and fail over, plus the cache and the app servers | One binary per node and an S3-compatible bucket; no consensus cluster to operate |
Use both. Actors are islands: each one’s data is fast to reach through it, and slow to query across. Keep Postgres for what spans everything (reporting, search, billing, analytics) and use actors for the hot, coordinated, real-time parts. A typical split: your existing API and database stay; chat, presence, rate limits, per-user workflows and device state move to actors; actors call your API with outbound HTTP, or push summaries to it, when the rest of the system needs to know.
Use cases
Each pattern below names the actor, what it keeps, and the features it leans on. Where the repository has a working example, it is linked.
Real-time rooms: chat, presence, collaboration, game lobbies
- One actor per room, document or match.
- Keeps: members, recent history (SQLite), whatever the room needs.
- Uses: WebSockets attached to the actor (they survive hibernation), broadcasts to every socket, alarms for timeouts (an idle match closes, a turn times out).
- Why actors: every message for a room arrives at the same place, in order, without a pub/sub layer between servers.
- Example:
examples/chat-room, also in TypeScript, Python, C, Zig, Go, D and MoonBit.
Rate limits and quotas
- One actor per API key, IP, user or tenant.
- Keeps: the current window and its count.
- Uses: one event at a time (the count is exact), an alarm to close the window; an idle key deletes itself, so unused keys cost nothing.
- Why actors: no lock, no race between servers, no shared counter that every request contends on.
- Example:
examples/rate-limiter.
Scheduled and long-running work: monitors, reminders, workflows
- One actor per monitored URL, reminder, order or workflow run.
- Keeps: its state and history; the next step.
- Uses: alarms (persisted with the actor, retried with backoff when the
handler fails, fired by another node if its owner dies), outbound HTTP,
exec-durablefor steps that must not be lost; for multi-step work, the durable workflows library (Rust and TypeScript, with clients in Go, Python, C and Zig), which runs each workflow on an ordinary actor. - Why actors: the schedule lives with the state it acts on; there is no cron, job table or lock to keep consistent.
- Example: Watch, an uptime monitor with one actor
per URL and a hub that delivers alerts to a webhook; what building it
taught us is in dogfood. Workflows:
examples/workflowsandexamples/ts-workflows.
Per-user state: sessions, carts, settings
- One actor per user, cart or session.
- Keeps: the session, the cart, the user’s settings.
- Uses: durable writes, alarms for expiry or reminders, outbound HTTP.
- Why actors: each user’s state is local to its code and touched by one event at a time, so concurrent tabs or retries cannot corrupt it.
AI agents
An agent is state plus a loop: a conversation’s history, what it has learned, the tools it called and what they returned, waiting for a model, a tool or a person, then acting again. That is an actor.
- One actor per conversation, agent or task (a ticket, a document, a customer).
- Keeps: the conversation and its memory in SQLite (messages, facts, summaries, tool results), surviving restarts and node failures.
- Uses:
- outbound HTTP to call a model and tools: hosted APIs by default
(public addresses only); a model server on your network (Ollama,
vLLM, an internal gateway) once
--allow-outboundopens its range, and--outbound-catrusts its private CA; - WebSockets to stream progress and answers to the user, attached to the agent’s actor and kept while it sleeps;
- alarms for anything that waits: a reminder, a follow-up, a retry of a failed tool call, a periodic check of an inbox or a feed. Delivered at least once, also when the node dies;
actors.callfor agents that work together (a planner and workers), with call cycles refused instead of deadlocking;- a counter actor per user or key to cap model spend, exact under load.
- outbound HTTP to call a model and tools: hosted APIs by default
(public addresses only); a model server on your network (Ollama,
vLLM, an internal gateway) once
- Why actors: two messages to the same conversation are handled in order, never interleaved; an agent waiting for a person or a slow tool costs disk, not memory; the history lives next to the code that uses it. Tool code you did not write (a plugin, or code a model produced) runs in the Wasm sandbox with CPU and memory limits, with the pre-alpha caveat given for customer code below.
- Sovereignty: model calls can stay on your network, and the history stays on your servers and in your bucket, for teams that cannot send conversations to a third party’s runtime.
- Example:
examples/agent, one actor per conversation calling an OpenAI-compatible endpoint (hosted, or Ollama through--allow-outbound), with the history in SQLite, a WebSocket feed, a reminder tool on an alarm and a cap on model calls per hour.
Limits today, and how to work with them:
- 30 s per event. An event (a request, an alarm, a call) runs at most
30 s of wall-clock time; programs embedding WasmKeep can change it
(
ActorConfig::wall_per_event),wasmkeep servecannot yet. A long model call can reach it: stream the answer to the user over the WebSocket as it arrives, and split long work into steps (one tool call or model turn per event, the next one scheduled with an alarm or a call), recording progress in SQLite so a cut step resumes. - No model runs inside an actor. Actors call models over HTTP; there is no GPU access from Wasm.
- No vector index built in. A conversation’s own memory is small enough to search in SQLite (stored embeddings, compared in code). Search across a large corpus belongs in a dedicated store (pgvector, Qdrant) that agents call.
- Language costs. Python and TypeScript actors carry their runtime: more memory per resident agent than Rust, C or Zig; TypeScript on QuickJS, the default engine, uses about a fifth of StarlingMonkey’s (741 KB against 3.4 MB) (languages, benchmarks).
Device twins and the edge
- One actor per device or site.
- Keeps: the device’s latest state and pending commands.
- Uses: alarms for missed heartbeats, WebSockets for live control.
- Where: on one small server per site, or a cluster with a local MinIO, without outbound network at run time.
Customer code in a SaaS product
- One actor per tenant or plugin instance.
- Keeps: the plugin’s own data, separate from every other tenant.
- Uses: the Wasm sandbox (no filesystem, sockets or environment unless granted; outbound HTTP limited to public addresses), a CPU budget per event, a memory cap per actor, access rules per class.
- Why actors: isolation of code and data per customer, from any language that compiles to a Wasm component.
- Caveat: today a process serves one tenant, and the sandbox protects the host from buggy code, not yet from a determined attacker (security model).
Where it fits operationally
- One VPS: a single binary, data on local disk. A side project can run here for years.
- A small cluster (three nodes or more): any S3-compatible bucket that supports conditional writes (AWS S3, Cloudflare R2, OVHcloud Object Storage, MinIO; R2 and OVHcloud tested), which each node checks at start; a dead node’s actors are restored on another, with no consensus cluster to run (three nodes, or the Compose example to try it locally).
- On-premises and sovereign deployments: everything, the bucket included, can stay on your hardware and in your jurisdiction.
- Hosting providers: the same binary can be offered as a managed service; WasmKeep itself has no hosted cloud.
Status
WasmKeep is pre-alpha. What exists and what is measured is in the ROADMAP and benchmarks. Clustering has been soaked against Cloudflare R2 and OVHcloud Object Storage: three nodes, one killed every 3 minutes under load, 36 kills over four 30-minute runs, nothing acknowledged lost (measured). It has not yet carried real users’ traffic.
WasmKeep and Cloudflare Workers
WasmKeep’s programming model resembles Cloudflare’s Durable Objects: one addressable instance per name, one event at a time, its own storage, alarms and WebSockets. This page says where each is the better choice and why. Numbers come from Benchmarks; everything else describes how the two are built. Two different things carry the Cloudflare name here: workerd, the open-source runtime anyone can run, and Cloudflare Workers, the hosted platform built on it. Much of what makes Workers strong is the platform, not the runtime.
Where WasmKeep is the better choice
- Durable actors on your own servers, with failover. Each actor’s SQLite database is replicated to any S3-compatible bucket, and when a node dies another takes its actors over. With one lease per node (ADR 0020), over twelve hours and 35 kills under load next to the bucket: writable again 18.2 s after the kill at the median, 24.4 s at p99, with nothing acknowledged lost; one actor was writable again only 90 s after the kill, most of it a restore whose total time nothing bounds yet (measured). workerd runs Durable Objects on one machine, without replication or failover between machines; that is the hosted platform’s.
- Any language, as an equal. Actors are standard WebAssembly components run by Wasmtime: Rust, TypeScript, Python, C, Zig and Go (TinyGo) (languages). Workers are built around JavaScript; other languages run as WebAssembly inside a JavaScript isolate, with JavaScript glue.
- Less CPU for compiled languages. On the same counter, WasmKeep with Rust used 104 µs of server CPU per request against workerd’s 128 µs on a 16-core workstation, and 39% of workerd’s on a 4-vCPU VM; reads and writes were about 2.6 times faster at the median. Memory per resident actor is about the same (250 KB against 277 KB) (measured). Compiled WebAssembly runs close to native speed, with no JavaScript engine in between.
- Your data, your bill. The data is SQLite files and a documented replication format (ADR 0007) in a bucket you own. There is no per-request price: a cluster costs its servers and its bucket (on R2, its requests too: measured).
- Durable workflows as a library, on actors and alarms, in Rust and TypeScript with clients in the Go, Python, C and Zig SDKs (workflows).
Where Workers is the better choice
- JavaScript-heavy code. V8 compiles JavaScript as it runs; WasmKeep’s TypeScript runs on QuickJS, an interpreter inside each actor, and used about 1.8 times workerd’s CPU per request on the same counter. Apps that do a lot of work in JavaScript belong on workerd.
- Maturity. Workers has years in production at Cloudflare’s scale, a
hardened V8 sandbox, web-standard APIs (
fetch, streams, the cache API), npm compatibility and mature tooling. WasmKeep is pre-alpha. - Concurrency inside a request. A Worker can await several things at
once. A WasmKeep handler is synchronous: an outbound
http.fetchblocks the actor until it answers, and the actor’s other events wait. - Hostile tenants side by side. Cloudflare runs untrusted code from many customers on shared machines. WasmKeep’s WebAssembly sandbox is strong, but a tier for tenants who do not trust each other (a process or microVM per tenant) is not built yet (security model).
- The platform around it. A global network that routes users to a nearby location, and managed key-value, queues, SQL and object storage next to the code. WasmKeep is self-hosted, in one region per cluster.
In short
WasmKeep is the better fit for stateful, self-hosted apps, especially in compiled languages, where the data should live in your own bucket and the cost should be your servers. Workers is the better fit for JavaScript-first apps that want a global edge and a mature ecosystem today. The two programming models are close: an app designed around one actor per room, user or document keeps its design on either, though its code has to be ported to the other’s APIs.
Cloudflare Workers, Durable Objects and workerd are Cloudflare’s products, named here only to identify them (licensing).
WasmKeep and celld
celld and WasmKeep solve the same problem the same way: Durable Object-style actors on your own servers, each with its own SQLite database, replicated to an S3-compatible bucket, and taken over by another node when one dies. This page says where each is the better choice. Numbers come from Benchmarks, where both run the same counter on the same machine and the same bucket: one node (measured) and three nodes with a node killed under writes, five runs with the nodes’ data on disk and five on tmpfs (measured). Everything else describes how the two are built, from their documentation. celld v0.6.3 was measured.
Two meanings of durable
The biggest difference is what a write waits for once there are several nodes.
- WasmKeep answers a durable write (
exec-durable) once the bucket holds it, on one node or many. A plainexecanswers before its upload, and may be lost with its node. - celld always waits. On one node, it waits for the bucket. With two or
more nodes, by default (
fleet), it answers once a follower node has the write on disk, or once the bucket has it, whichever comes first; the upload follows.CELLD_DURABILITY=bucketmakes it wait for the bucket as WasmKeep does.
A follower’s disk is usually much closer than a bucket, so celld’s default should answer writes sooner when the bucket is in the cloud. That case is not measured yet. In the three-node runs with the nodes’ data on one shared disk, fleet mode was celld’s slowest in every run (and it completed fewer writes than bucket mode on tmpfs too), which is not explained. Against a write that waits for the bucket in both, the numbers below compare like with like.
Neither lost an acknowledged durable write. Every write carried a token of its own, and every token was read back after each restart and at the end: sixty kills for each of WasmKeep durable, celld fleet and celld bucket, half of them with the killed node’s disk removed before it restarted. A control run that must lose writes lost them every time, so the check finds a loss. What it does not test: a power loss on the nodes that survive, and nodes on separate machines (lost writes). The figures below are medians of five runs with the nodes’ data on disk, unless tmpfs is named.
Where WasmKeep is the better choice
- Any language, as an equal. Actors are standard WebAssembly components run by Wasmtime: Rust, TypeScript, Python, C, Zig and Go (TinyGo), with D and MoonBit experimental (languages). celld runs the Workers API on V8: JavaScript first, other languages as WebAssembly inside it.
- Less CPU and memory on one node. On the same counter, each write in the bucket, WasmKeep with Rust used 85% of celld’s CPU per request, held 1000 actors in 334 against 388 MB, answered a write in 2.31 against 2.55 ms at the median (0.53 against 0.93 ms on tmpfs) and read about twice as fast. On disk its throughput was close to celld’s, about 5% behind at the median (measured).
- Faster reads and less memory on three nodes. Reads took 92 against about 220 µs at the median, and three nodes held 1000 actors in 459 against about 520 MB (measured).
- Encrypted traffic between nodes. WasmKeep nodes talk mutual TLS by default (cluster); celld has no TLS between peers, so its peer listener belongs on a private network.
- A write that does not wait. For data that can be rebuilt, a plain
execanswers before its upload: 0.55 ms at the median on three nodes, and 13,416 writes per second against 1,710 durable. It is the one mode that lost writes when a node was killed: 0 to 3 of about 86,000 in a run.
Where celld is the better choice
- Existing Workers code. celld runs the Workers API and Durable Objects as they are: the same counter ran on workerd and celld unchanged. WasmKeep has its own API, and Workers compatibility is not planned (roadmap).
- Faster takeover and start. With each one’s defaults, a dead node’s actors were writable again after 9 to 10 s on celld against 15 to 16 s on WasmKeep (medians, data on disk), celld sooner in every run; both are close to their default lease timings (10 s and 15 s). A celld node starts in about 0.1 s; a WasmKeep cluster node’s first start checks for 15 s that no other process has its node id before it listens, and a node restarted after its peers fenced it was ready in about 1.6 s.
- Durable writes on three nodes with data on disk, waiting for the bucket: close, celld ahead. In bucket mode, eight clients completed 1,857 writes per second on celld against 1,710 on WasmKeep, about 8% apart where runs differ by 2 to 4%, at the same CPU per write; WasmKeep answered a single write sooner at the median (2.30 against 2.68 ms) and later at p99 (measured). With the nodes’ data on tmpfs the order reverses (9,137 against 5,888 writes per second); the runs change only where the data is, and do not say which step of a write the disk slows.
- JavaScript-heavy code. V8 compiles JavaScript as it runs; WasmKeep’s TypeScript runs on QuickJS, an interpreter (WasmKeep and Workers).
- More of the platform built in. celld comes with key-value, queues, D1-style SQL and R2-style object storage. WasmKeep offers key-value, queues and workflows as SDK patterns on actors (key-value and queues, workflows).
Not measured yet
- A cloud bucket, next to the nodes. It decides whether celld’s fleet mode answers writes sooner than waiting for the bucket, and by how much.
- Nodes on separate machines. The three nodes ran as processes on one machine and shared its disk.
- Longer runs. WasmKeep has a twelve-hour soak against a real bucket (measured); celld was run for minutes.
In short
Both keep each actor’s data in SQLite, in a bucket you own, and no acknowledged durable write was lost through the kills measured here. WasmKeep fits stateful apps in compiled languages, with less memory per actor, less CPU on one node, faster reads and encrypted traffic between nodes. celld fits Workers code you already have, and starts and takes over faster with its defaults. Which answers a durable write sooner on a cloud bucket is the open question, and the next measurement.
celld is Deno’s project, and Cloudflare Workers and Durable Objects are Cloudflare’s products, named here only to identify them (licensing).
Your first actor in 10 minutes
You will write a small actor in Rust, build it into a WebAssembly component, serve it with WasmKeep, and see its state survive a restart.
Applies to: WasmKeep 0.3.0 (pre-alpha). Commands assume Linux; on macOS,
build the server from source (step 1).
1. Get WasmKeep
Install the wasmkeep binary from a release, as in
Install (Linux, x86_64 or aarch64), and check it
answers:
wasmkeep --version
On macOS, or to run the server from source: git clone https://github.com/wasmkeep/wasmkeep, then cargo build --release -p wasmkeep-cli in it writes target/release/wasmkeep.
To build the actor you need rustup with Rust 1.97 or later, and the WebAssembly target:
rustup target add wasm32-wasip2
2. Create the actor
An actor is a Rust library compiled to a component. Create it anywhere outside a clone of WasmKeep, and add the SDK from crates.io:
cargo new --lib visits && cd visits
cargo add wasmkeep-sdk
cargo add takes the newest version: the SDK carries the version of the
server it is released with, so keep the two the same
(cargo add wasmkeep-sdk@0.3.0 names one). Add to Cargo.toml:
[lib]
crate-type = ["cdylib"]
# Keeps the component small, as in every example.
[profile.release]
opt-level = "s"
strip = true
lto = true
codegen-units = 1
Replace src/lib.rs with:
#![allow(unused)]
fn main() {
use wasmkeep_sdk::http::{self, IncomingRequest, ResponseOutparam};
use wasmkeep_sdk::{Actor, Value, params, storage};
struct Visits;
impl Actor for Visits {
// Runs once per actor (and again after you deploy a new version):
// create your tables here.
fn on_init() -> Result<(), String> {
storage::exec(
"CREATE TABLE IF NOT EXISTS visits (who TEXT NOT NULL)",
&[],
)
.map(drop)
.map_err(|e| e.to_string())
}
}
impl http::Guest for Visits {
fn handle(request: IncomingRequest, out: ResponseOutparam) {
// `/?who=alice` records a visit by alice.
let path = request.path_with_query().unwrap_or_default();
let who = path.split_once("who=").map_or("someone", |(_, w)| w);
let result = storage::exec("INSERT INTO visits (who) VALUES (?1)", ¶ms![who])
.and_then(|_| storage::query("SELECT count(*) AS n FROM visits", &[]));
match result {
Ok(rows) => {
let n = rows.get(0, "n").and_then(Value::as_i64).unwrap_or(0);
let body = format!("hello {who}, you are visitor number {n}\n");
http::respond(out, 200, "text/plain", body.as_bytes());
}
Err(e) => http::respond(out, 500, "text/plain", e.to_string().as_bytes()),
}
}
}
wasmkeep_sdk::export!(Visits);
}
Two traits, one macro:
Actorholds the lifecycle hooks (on_init,on_alarm, andon_websocket_open,on_websocket_message,on_websocket_close). All are optional; by default an actor refuses WebSockets.http::Guestis the request handler (standardwasi:http).export!wires both into the component.
3. Build it
cargo build --release --target wasm32-wasip2
The component is target/wasm32-wasip2/release/visits.wasm, close to the
81 KB of the Rust counter, which uses the same release profile.
4. Serve it
wasmkeep serve --actor visits=target/wasm32-wasip2/release/visits.wasm
Every actor lives at /actor/<class>/<name>/.... The class is visits; the
name is anything you like, and each name is a separate actor. In another
terminal:
curl 'localhost:8080/actor/visits/home?who=alice'
# hello alice, you are visitor number 1
curl 'localhost:8080/actor/visits/home?who=bob'
# hello bob, you are visitor number 2
curl 'localhost:8080/actor/visits/about?who=carol'
# hello carol, you are visitor number 1
home and about are two actors, each with its own SQLite database under
.wasmkeep/data/actors/default/visits/, in the directory the server
started in.
5. Restart it
Stop the server with Ctrl-C, start it again with the same command, and:
curl 'localhost:8080/actor/visits/home?who=dave'
# hello dave, you are visitor number 3
The count survived because it lives in the actor’s database, not in memory.
What just happened
- One event at a time. Requests to
homerun one after another, in arrival order, soINSERTthenSELECTcan never interleave with another request tohome. You write no locks. Requests to different actors run in parallel. - Hibernation. After 30 s without events an actor is dropped from memory; the next request brings it back in about a millisecond. Its database stays on disk.
- Sandboxed. The component can reach its own database, alarms, calls to
other actors, WebSockets, outbound HTTP, clocks, randomness and its
configuration (the
envinterface; the WASI environment stays empty), and nothing else: no files, no sockets. What it prints (println!) goes to the node’s log. - Limits. Each event gets 50 ms of CPU (and 30 s of wall time), and each of an actor’s linear memories (at most two) is capped at 128 MiB, by default. A guest that loops forever is stopped and gets a 500; the server and other actors carry on.
Next
wasmkeep dev .rebuilds and reloads your actor every time you save, keeping its data.examples/rate-limiteruses alarms: code that runs later, at least once, even across restarts.- The release carries more to try without building: key-value and queues and workflows, as classes your actor calls.
wasmkeep_sdk::actors::calllets one actor call another.- The design and its guarantees are in Concepts, with an overview of the architecture.
Languages
An actor is a WebAssembly component implementing the
wasmkeep:actor world: an ordinary
wasi:http request handler, plus on-init, on-alarm and three WebSocket
hooks, with access to its SQLite database, alarms, actor calls and WebSockets.
Any language whose toolchain can produce a WASI 0.2 component can write
actors. WasmKeep runs them directly on Wasmtime, with no JavaScript or other
runtime in between unless the language brings its own.
Which language to use
WasmKeep runs WASI 0.2 components on a server. A language works if its
toolchain can produce a component that implements the wasmkeep:actor world.
Most “WebAssembly support” you read about targets the browser instead
(wasm-bindgen, Emscripten, Blazor, Pyodide, Kotlin/Wasm for JS): that is a
different target, and does not make a language usable here.
| Tier | Meaning |
|---|---|
| Recommended | SDK, tutorial, examples passing the shared tests, and benchmark rows close to the best measured; a small component with no embedded runtime. |
| Supported | SDK; its counter and chat room pass the shared tests in CI with pinned tools; trade-offs stated with numbers from the bench files. |
| Experimental | A counter passes the shared tests, but something Supported requires is missing: an SDK, a chat room, numbers from the bench files, or stable upstream tooling. No compatibility promise. |
| Not yet | Cannot produce a working actor today; the entry says what blocks it. |
Today
| Language | Tier | How it becomes a component | Why this tier |
|---|---|---|---|
| Rust | Recommended | cargo build --target wasm32-wasip2, wasmkeep-sdk | SDK, examples, tutorial; 81 KB; 104 µs of CPU per request, 250 KB per resident actor (measured); no adapter (WASI 0.2 directly) |
| Zig | Supported | Zig SDK over the C SDK; the WASI 0.1 adapter | Counter, chat room and jobs app (KV and queue clients) pass; 30 KB, the smallest. Performs like Rust: 107 µs of CPU per request, 242 KB per actor (with --stack 1048576; Zig’s 16 MiB default stack takes megabytes more) (measured). Recommended once it has a guide |
| C / C++ | Supported | C SDK over wit-bindgen c; clang + wasi-libc, the WASI 0.1 adapter | Counter, chat room and jobs app (KV and queue clients) pass; 170 KB. Performs like Rust: 110 µs of CPU per request, 244 KB per actor (measured). Recommended once it has a guide |
| TypeScript / JavaScript | Supported (embedded runtime) | @wasmkeep/sdk: esbuild, then componentize-qjs (QuickJS embedded, pre-initialized); or ComponentizeJS (StarlingMonkey) with --engine starlingmonkey (below) | Counter, chat room and jobs app (KV and queue clients) pass on both engines. QuickJS, the default: 1.7 MB; 230 µs of CPU per request (2.2× Rust) and 741 KB per actor (3×) (measured), few Web APIs. StarlingMonkey: Web APIs and 1.5× faster plain JavaScript, for 4.6× the memory per actor (measured) |
| Python | Supported (embedded runtime) | Python SDK, componentize-py (CPython embedded) | Counter, chat room and jobs app (KV and queue clients) pass. 18.7 MB, the largest; 259 µs of CPU per request (2.5× Rust) and 7.8 MB per actor (31×) (measured) |
| Go (TinyGo) | Supported | Go SDK; TinyGo (0.39 in CI, 0.43 tried by hand) -target=wasip2, which writes a component directly | Counter, chat room and jobs app (KV, queue and workflow clients) pass; 162 KB. 248 µs of CPU per request (2.4× Rust) with a p99 up to 40× its median (the garbage collector), 390 KB per actor (measured, on the counter before the SDK: the same host calls without the wrapper); see Go |
| Go (standard) | Experimental | componentize-go 0.5.1 with Go 1.27, straight on its bindings | The counter passes; 2.7 MB. No SDK yet (the Go SDK is for TinyGo’s bindings), no numbers from bench/run.sh (see standard Go) |
| D | Experimental | D SDK over wit-bindgen d; LDC (1.43 in CI) with -betterC, clang + wasi-libc, the WASI 0.1 adapter | Counter and chat room pass; 88 KB. No numbers from bench/run.sh yet, no outbound HTTP or key-value and queue clients (see D) |
| MoonBit | Experimental | MoonBit SDK over wit-bindgen moonbit; moon build --target wasm (MoonBit 0.10.20 in CI), then wasm-tools component embed --encoding utf16 and component new | Counter and chat room pass; 49 KB. The language is pre-1.0; no numbers from bench/run.sh yet, no outbound HTTP or key-value and queue clients (see MoonBit) |
| C# / .NET | Experimental (spike) | componentize-dotnet 0.8.0-preview with NativeAOT-LLVM, straight on its bindings | The counter passes; 929 KB stripped. No SDK, chat room or numbers from bench/run.sh; every tool is preview (see C#) |
Getting an SDK
Each SDK has the version of the server it is released with
(versions); here for 0.3.0.
| Language | Get it | Build an actor |
|---|---|---|
| Rust | cargo add wasmkeep-sdk (crates.io) | cargo build --release --target wasm32-wasip2 (tutorial) |
| TypeScript / JavaScript | npm install @wasmkeep/sdk (npm) | npx wasmkeep-ts build src/index.ts -o actor.wasm (README) |
| Python | pip install wasmkeep (PyPI) | python -m wasmkeep build app -o actor.wasm (README) |
| Go (TinyGo) | go get github.com/wasmkeep/wasmkeep/sdks/go@v0.3.0 (a module of this repository) | tinygo build -target=wasip2, with the WIT the module ships (README) |
| C / C++ | wasmkeep-c-sdk-0.3.0.tar.gz, a file of the release | The build command in the archive’s README (C SDK) |
| Zig | wasmkeep-zig-sdk-0.3.0.tar.gz, a file of the release | The build command in the archive’s README (Zig SDK) |
| D, MoonBit, C#, standard Go | The repository’s source at the release’s tag | Each example’s build.sh |
The release’s files are signed: check a download.
Next, in order
Each is experimental once a counter passes the shared tests; numbers
from bench/run.sh come before it is supported.
- An SDK for standard Go, which now builds actors through componentize-go (below).
Bring your own through the C SDK, the way Zig does: Odin and Nim should work (C-sized components); Swift and Haskell are possible with more effort (no bindings generator, larger runtimes). Recipes, not SDKs.
Not yet
| Language | What blocks it | What would unblock it |
|---|---|---|
| Kotlin | Kotlin/Wasm’s WASI target is still WASI 0.1; component support is an early JetBrains prototype on a forked bindings generator; it compiles to garbage-collected Wasm | JetBrains shipping WASI 0.2 and component-model support (on their roadmap), and WasmKeep enabling Wasm GC |
| Java | No maintained path to a component: TeaVM removed its WASI backend, wit-bindgen removed its Java generator, GraalVM’s Wasm output targets browsers and Node | A maintained compiler and bindings generator |
| Scala, OCaml | Compile to garbage-collected Wasm; WasmKeep’s Wasmtime is built without Wasm GC, and the component model cannot pass GC data yet | Enabling Wasm GC in WasmKeep (its own decision: an ADR, benchmarks, pool sizing) |
| Swift | Stable WASI 0.1 SDK, but no WIT bindings generator for imports | A Swift wit-bindgen backend |
| AssemblyScript | Its maintainers reject the component model | — |
| Ruby | No bindings generator; very large runtime | A componentize-rb |
| PHP | The interpreter runs on WASI 0.1 as a CGI-style module (one script per request), not as a component; no bindings generator, so PHP cannot call storage or export the actor’s hooks | A componentize-php (the interpreter as a library, wit-dylib glue, pre-initialization), the route componentize-py takes for Python |
| Grain, Crystal | No component support | Upstream work |
Lua (via wit-dylib) | Cannot export interface functions yet (our world needs incoming-handler), and needs Wasm exceptions | Upstream wit-dylib progress, Wasm GC/exceptions in WasmKeep |
| JavaScript via Javy | Produces core modules, not components | Use TypeScript on QuickJS instead |
Component sizes
The same counter, as built by each example’s build.sh (sizes on disk; see
benchmarks for speed and memory):
| Language | Component |
|---|---|
| Zig | 30 KB |
| MoonBit | 49 KB |
| Rust | 81 KB |
| D | 88 KB |
| Go (TinyGo) | 162 KB |
| C | 170 KB |
| C# | 929 KB (NativeAOT, debug sections stripped) |
| TypeScript | 1.7 MB (QuickJS engine, the default) |
| Go (standard) | 2.7 MB (componentize-go; Go’s runtime) |
| TypeScript on StarlingMonkey | 13.4 MB (StarlingMonkey JS engine) |
| Python | 18.7 MB (CPython) |
TypeScript on QuickJS
QuickJS is the default engine
(ADR 0018):
wasmkeep-ts build (npm run build in the TypeScript examples) uses
componentize-qjs 0.5.0,
which the pinned jco 1.41.0 already ships: the
QuickJS-ng engine, pre-initialized
with your bundle, built without component-model async (WasmKeep’s hooks are
synchronous). wasmkeep-ts build --engine starlingmonkey
(npm run build:starlingmonkey) builds the same source with ComponentizeJS
and StarlingMonkey instead.
The counter, the chat room and the jobs app pass the same tests on both
engines (crates/wasmkeep-server/tests/languages.rs, tests/chat.rs,
tests/jobs_langs.rs). Measured on one machine
(benchmarks),
QuickJS against StarlingMonkey: 1.7 MB instead of 13.4 MB, 741 KB of
memory per resident actor instead of 3.4 MB, and 230 µs of CPU per request
instead of 368 µs on the counter; but 5.8 ms instead of 3.8 ms on plain
JavaScript work.
Choose StarlingMonkey when the code (or an npm package it uses) needs the Web APIs below, or spends milliseconds computing in JavaScript per event.
What does not work, or works differently, on QuickJS:
- Fewer Web APIs. QuickJS has no
URL,URLSearchParams,crypto(getRandomValues,randomUUID),fetch,Headers,Request,Response, streams,Blob,AbortController,setTimeoutorstructuredClone; StarlingMonkey has all of these. Neither hasIntl. Code or npm packages that use them fail at build time (during pre-initialization) or when called, with an error that names--engine starlingmonkey. They are not stubbed, so libraries that testtypeof fetchstill pick their fallback. QuickJS does haveatob,btoa,queueMicrotask,performanceandWeakRef. For outbound HTTP on either engine, the SDK hashttp.fetch: synchronous, as handlers are (StarlingMonkey’s ownfetchreturns a promise a handler cannot await). consolecomes from the SDK, writing one line per call to stdout (log,info,debug) or stderr (warn,error), as StarlingMonkey’s does; objects are shown as JSON. WasmKeep logs both (what actors print).TextEncoderandTextDecodercome from the SDK, UTF-8 only and without streamingdecode. The SDK installs them when the engine lacks them, so the same code runs on both engines.- A WIT
noneisnull, notundefined. The SDK’senv.getandalarms.getreturnundefinedon both engines; code that importswasmkeep:*orwasi:*interfaces directly seesnull. - Slower JavaScript. Neither engine has a JIT, and QuickJS’s interpreter is slower: 5.8 ms against 3.8 ms for the same plain JavaScript work (measured). The counter spends little time in JavaScript, so there QuickJS wins on instantiation and the host.
- Not covered by the shared tests:
execDurablefrom TypeScript on either engine (actors.callis, through the jobs app’s key-value and queue clients). Alarms were checked by hand on QuickJS.
Go
TinyGo’s wasip2 target writes a component directly, against a WIT world
it is given. The Go SDK is a wasmkeep package over the
bindings wit-bindgen-go generates (committed in the module, so an app
needs only TinyGo): an Actor of optional hooks, Exec, Query and
Rows, alarms, Call, WebSockets, Env, Self and a synchronous
Fetch, plus the key-value, queue and
workflow clients. TinyGo’s runtime imports
wasi:filesystem whether or not the program uses it, so the module’s
wit/ adds those imports to the actor world (actor-tinygo); the host
provides them inert (no directories).
An app adds the module with go get github.com/wasmkeep/wasmkeep/sdks/go@v0.3.0: the module carries the
server’s version number. The wasmkeep package starts at v0.2.0
(v0.1.0 has only the clients); see the SDK’s
Install.
The counter, chat room
and jobs app pass the shared tests, and
fetch_langs checks Fetch against the Rust SDK’s http::fetch.
Trade-offs:
- A garbage collector. TinyGo’s runs inside the request: the counter’s p99 is 10× (reads) to 40× (writes) its median, against under 1.5× for Rust (measured). Allocating less per event shortens it.
- TinyGo, not Go. TinyGo compiles most of the standard library, not
all of it (its documentation lists which packages build); the SDK keeps
to packages it handles well (no
fmtor reflection). - Size. 162 KB for the counter, about twice Rust’s;
strconv’s number parsing alone adds about 50 KB, so the SDK reads integers from SQLite without it. - Not covered by the shared tests:
SetAlarmand theAlarmhook,ExecDurableandDeleteSelffrom Go (Callis, through the jobs app’s clients).
Standard Go, through componentize-go
Standard Go (not TinyGo) builds actors through componentize-go 0.5.1 with Go 1.27: the counter passes the same counter contract as Rust, and CI builds it. What blocked our own attempt before (Go’s runtime assumes it owns all linear memory past its data, while the WASI 0.1 adapter needs memory before Go starts) does not show: the component instantiates, survives hibernation and restarts.
What works:
- The whole world.
componentize-go bindingsgenerates Go packages for every interface ofcrates/wasmkeep-wit/wit, and the app implements two packages it names:export_wit_world(the five hooks, returningwitTypes.Result[witTypes.Unit, string]) andexport_wasi_http_incoming_handler(Handle). The component imports exactly the world’s 0.2.12 interfaces. - Go’s own compiler and runtime, not TinyGo’s subset of them: what
builds with
go buildforwasip1builds here.
What is missing:
- An SDK. componentize-go’s bindings (
go.bytecodealliance.org/pkg) are not thewit-bindgen-goones the Go SDK wraps for TinyGo, so the SDK does not build on standard Go yet. Itskv,queueandworkflowclients import no bindings and would carry over. - Size. 2.7 MB, most of it Go’s runtime (stripping custom sections
saves under 3%), against 162 KB on TinyGo; no numbers from
bench/run.shfor CPU or memory per actor yet. - Rough edges. The module must be named
wit_component(the bindings import it by that path),bindingsrewritesgo.modfrom its own template on every run (the example’sbuild.shkeeps the committed one), the build needs-buildvcs=falseinside a git checkout, and the tool downloads its own binary from its GitHub release into the user’s cache directory on first use.
D
LDC compiles D with -betterC (no garbage collector, no D runtime, no
exceptions) to objects for wasm32-unknown-wasi; clang links them
against wasi-libc as a reactor, and the WASI 0.1 adapter makes the
component, as for C. wit-bindgen d generates the bindings, and the
D SDK wraps them: a struct of static hooks exported with
mixin Actor!S, exec, query and Rows with parameters as plain D
arguments, alarms, call, WebSockets, env and self. Without a
garbage collector, everything the SDK returns lives until the event ends
and is freed then.
The counter and chat room
pass the shared tests. D stays experimental until bench/run.sh measures
it; the SDK has no outbound HTTP, key-value, queue or workflow clients
yet. -betterC also means no D standard library parts that need the
runtime (most of Phobos): text is built with reply’s printf formats or
arena memory.
MoonBit
wit-bindgen moonbit generates the bindings as a MoonBit module whose
world package declares the hooks (declare pub fn on_init, …); the
MoonBit SDK’s build.sh adds the SDK package, the app
package and a small glue file that implements those declarations by
calling the app’s actor (an @wasmkeep.Actor of optional hooks). moon build --target wasm makes a core module with no WASI imports of its own,
and wasm-tools makes the component, with UTF-16 strings (MoonBit’s).
The SDK wraps storage (exec, query, Rows), alarms, call,
WebSockets, env and self_address; failures are MoonBit errors
(Failure), raised from the SDK and caught at the hooks.
The counter and
chat room pass the shared tests.
MoonBit stays experimental: the language is pre-1.0 (its string layout
changed in 2026, so the compiler, core library and wit-bindgen are
pinned together), bench/run.sh does not measure it yet, and the SDK has
no outbound HTTP, key-value, queue or workflow clients. MoonBit’s servers
publish versions by their compiler version (0.10.20+65efd5b93, as
moon version --all prints it), not by moon’s dated version; CI pins
that one, checked by SHA-256.
C# (spike)
C# can produce a WasmKeep actor today. The counter is built with componentize-dotnet 0.8.0-preview00011 and NativeAOT-LLVM 10.0.0-rc.1 on the .NET 10 SDK, and passes the same counter contract as Rust; CI builds it.
What works:
- The whole world. componentize-dotnet runs
wit-bindgen c-sharponcrates/wasmkeep-wit/witat every build; the app implements two generated interfaces (IActorWorldExportsfor the five hooks,IIncomingHandlerExportsfor requests) and calls the imports as static methods (IStorageImports.Exec, …). - One build command.
dotnet buildrestores the compiler and the tools it needs and writes a WASI 0.2 component directly; no adapter. - Errors as exceptions. A WIT error result is thrown as a
WitException; a hook fails its event by throwingWitException<string>.
What is missing:
- Everything is preview. The compiler comes from Microsoft’s dotnet-experimental feed (a release candidate), the packages are previews, and the first restore is about 800 MB.
- No macOS. At the version componentize-dotnet uses, the
NativeAOT-LLVM compiler is published for Linux (x64 and arm64) and
Windows x64; the macOS packages on the feed are years older, and
Counter.csprojnames the Linux x64 one. - An older filesystem import. The component imports
wasi:filesystem@0.2.6where the world has 0.2.12, sowasm-tools component targetsrejects it; WasmKeep links the imports semver-compatibly and runs it. - No SDK. The code names generated types in full
(
ActorWorld.wit.Imports.wasi.http.v0_2_12.ITypesImports.OutgoingResponse) and does its own HTTP plumbing; an SDK on the bindings, the chat room and numbers frombench/run.shcome before a tier above experimental. - Size. 2.5 MB as built, 929 KB with its debug sections stripped
(
wasm-tools strip --all, whichbuild.shruns): NativeAOT compiles the runtime and the garbage collector in.
Versions
The SDKs carry the server’s version: one number for the wasmkeep binary,
the Rust crate and every other SDK manifest, changed together. Use the SDK
with the same version as the server you run: other pairings are not
tested.
| SDK | Server | WIT package |
|---|---|---|
| 0.3.0 | 0.3.0 | wasmkeep:actor@0.2.0 |
| 0.2.0 | 0.2.0 | wasmkeep:actor@0.2.0 |
| 0.1.0 | 0.1.0 | wasmkeep:actor@0.2.0 |
0.2.0 is the first version on the registries (crates.io, npm, PyPI);
0.1.0 was used from a clone of the repository. That the SDK and the WIT
package both read 0.2.0 in the 0.2.0 row is a coincidence.
The WIT package version is another number: it is the version of the
interface between a component and the host
(actor.wit), and changes only when
that interface does.
scripts/check-versions.py (run by ./scripts/check.sh and CI) fails when a
manifest it lists has another version than the workspace’s in Cargo.toml;
an SDK that gains a manifest with a version is added to that list. The C
SDK’s is WASMKEEP_SDK_VERSION in wasmkeep.h, the Zig SDK’s version in
wasmkeep.zig; a release carries both SDKs as archives of that version
(wasmkeep-c-sdk-<version>.tar.gz, wasmkeep-zig-sdk-<version>.tar.gz).
What the tiers require
- Experimental: a counter passes the same black-box test as the Rust one (create, increment, persist across hibernation and restart).
- Supported: an SDK; the counter and the chat room pass the shared tests
(
--test languages,--test chat); it builds in CI from a clean checkout with pinned tool versions; this page states its trade-offs with numbers frombench/results. - Recommended: all of that, a guide (how to start an actor and call the storage, alarm, call and WebSocket APIs; today only Rust has one), and benchmark rows close to the best measured.
Languages that embed an interpreter or a garbage-collected runtime
(JavaScript, Python, Go, C#) produce larger components and wake more slowly than
Rust, C or Zig. The supported ones’ numbers are published from bench/run.sh like the
others, so the trade-off is visible before you choose.
Bring your own
If your language can produce a component that imports and exports what the
world declares, it works: there is nothing WasmKeep-specific in the binary
format. Generate bindings from
crates/wasmkeep-wit/wit with your toolchain’s
wit-bindgen (or equivalent), and check the result with
wasm-tools component wit your.wasm.
Concepts
What an actor is, what happens to it over its life, and what WasmKeep promises about it. Read What is WasmKeep? first for the why; this page is the how. Exact signatures are in the API reference.
Addresses
An actor’s address is tenant/class/name:
- class: one compiled component, given a name when it is served
(
--actor chat=chat_room.wasm) or deployed. - name: 1 to 256 bytes of any text without
/or control characters, other than.and... Every distinct name is a separate actor with its own database. There is no “create” step: the first event to a name creates it. - tenant: the owner of apps (a team, or a client of the operator; not
a customer of your app, which is an actor name). A node started with
--actorserves the tenantdefault;--app acme/shopserves appshopof tenantacme. What an app is, and what is planned for it, is in ADR 0021.
Class and tenant are identifiers: lowercase letters, digits and -,
starting with a letter, up to 64 bytes ([a-z][a-z0-9-]{0,63}). So is
the app name in --app TENANT/APP: the first wasmkeep deploy of an app
refuses a tenant or app name outside it. An app deployed before that rule
keeps its name. Only the first deploy checks it: wasmkeep secrets set
before that accepts any name, so set secrets after the first deploy, or
check the name yourself.
Over HTTP, an actor lives at /actor/<class>/<name>, and anything after the
name is the path it sees:
GET /actor/chat/lobby/history?limit=20
→ class "chat", actor "lobby", which sees GET /history?limit=20
Names are URL-decoded (room%201 is the actor room 1). The path an actor
sees has its address removed; it reads its own address with
actors.self-address (actors::self_address() in Rust, actors.self() in
TypeScript), for links to itself or anything keyed by its name.
Who may create an actor. By default anyone who can reach the server
can create an actor by requesting a new name. For classes whose actors
another actor manages, serve them with --access CLASS=existing: outside
requests then reach only actors that exist, and a new name answers 404
without creating anything (no database, no lease, nothing in the bucket).
Actors create them with actors.call. --access CLASS=existing:main,admin does the same but lets outside
requests create those names (a class’s well-known actors).
--access CLASS=internal refuses
outside requests altogether (403): only actors and alarms reach the class.
See ADR 0010. An app deployed with
wasmkeep deploy carries its rules in the deploy instead
(wasmkeep deploy --access CLASS=MODE), so every node applies the same ones
with the code they protect (Running a cluster). Paths outside
/actor/ go to the optional stateless component given to serve, except
/_wasmkeep/, which the server keeps for itself (/_wasmkeep/health and
/_wasmkeep/ready for load balancers, see Monitoring).
Events
Everything that reaches an actor is an event: an HTTP request, a WebSocket open, message or close, an alarm, or a call from another actor.
- An actor handles one event at a time, in arrival order. Its code never runs concurrently with itself, so it needs no locks.
- Different actors run in parallel. Throughput comes from many actors, not one busy actor: split hot state (one actor per room, not one per app).
- Each actor has a queue of up to 1,024 waiting events. A request that
finds it full is answered
429 Too Many Requests. A request received whole (no body, or one that arrived in the first read) whose client closes its connection while it waits is not run, nor an actor’s call whose caller timed out meanwhile (nobody would get the answer, and nothing it would write was acknowledged); one that has started runs to its end. - A node that cannot take an actor for now (the bucket does not answer, or
the actor already ran a newer deploy than this node has, which it is
fetching) answers
503 Service UnavailablewithRetry-After: 1: retry. A fault a retry will not fix (an unreadable ownership record, a history that cannot be restored) is a500. - An address without a mailbox needs resident capacity, including a new,
hibernated or failed-over actor. At capacity, admission waits up to 250 ms
for an idle actor to hibernate and close its resources. If none becomes
free, HTTP and local
actors.callreceive503 Service UnavailablewithRetry-After: 1before their event runs. A request for an actor another node owns is relayed without reserving a local slot when this node knows the owner (learned in the last second, or read from the actor’s ownership record); if the record cannot be read in time, the request goes through local admission like any other. A due alarm waits for capacity like a request, then retries with backoff and jitter. Each request that waits evicts at most one idle actor, however often it is woken. - A WebSocket message waking an evicted actor waits for capacity up to the configured event wall limit. Sustained exhaustion closes it with 1013. Close callbacks remain pending through capacity or queue pressure; pending cleanup continues to count against socket limits until delivered or the service shuts down.
- An event may use 50 ms of CPU (
--cpu-budget-ms) and 30 s of wall time, including time waiting on storage, calls and outbound HTTP. Time in host calls and time the OS spends on other threads do not count against the CPU budget. An event over either limit is stopped. - SQL has its own CPU budget: 2 s per event for all of the event’s
statements together (
--sql-cpu-budget-ms). Past it, the running statement stops withquota-exceededand later ones are refused until the event ends. - An event that traps or is stopped fails: a request gets a
500, a transaction it left open is rolled back, and the actor’s instance is discarded (the next event gets a fresh one, with the same database).
Life cycle
first event ─▶ on-init ─▶ resident ──idle 30 s──▶ hibernated
▲ │ ▲ │
new version │ └──── next event ◀─────┘
deployed ▼
delete-self ─▶ gone (the next event starts afresh)
on-initruns before the first event an instance handles with a given component version: once for a new actor, and again after each deploy. Use it to create and migrate tables (CREATE TABLE IF NOT EXISTS, thenALTER TABLEguarded by a version you keep in a table). If it fails, the event fails andon-initis tried again with the next event.- Resident: the instance stays in memory between events, so in-memory variables survive from one event to the next. Treat them as a cache: they are lost on hibernation, a crash, a deploy or a move to another node.
- Hibernation: after
--idle-timeout-secs(30 s) without events, the instance is dropped. Its database, its pending alarm and its WebSockets stay. The next event wakes it in about a millisecond. delete-selfremoves the actor once the current event ends: its database and alarm are gone, and the next event to the address starts a new actor.
Storage
Each actor owns one SQLite database. Nothing else can read or write it.
execruns a statement and returns the rows changed;queryreturns rows. One statement per call, with?parameters.- Outside a transaction each statement commits on its own.
BEGIN…COMMITacross several calls is allowed within one event; a transaction still open when the event ends is rolled back. execreturns once the write is committed locally.exec-durablereturns only once it is durable: replicated to the bucket in a cluster, fsynced on a single node. Use it when you are about to tell someone “done” (a payment, an order). It costs about a millisecond more locally, and a bucket round trip in a cluster.- Inside a transaction,
exec-durabletakes only theCOMMIT(orEND): run the transaction’s statements withexec, thenexec-durable("COMMIT")makes the whole transaction durable. Any other statement there is refused withnot-allowedbefore it runs, since nothing in a transaction is durable before its commit. - A database is capped at 1 GB by default; past that, writes fail with
quota-exceededand reads still work. - Values (text and blobs) are capped at 16 MB, and so is everything one
queryreturns; past that,quota-exceeded. Page through big results. - A statement runs at most as long as the event may (30 s by default);
past that it is stopped with
quota-exceeded. - One event may write about 256 MB (counting every rewrite of a page);
past that, writes fail with
quota-exceededuntil the event ends. Split bulk work across events, for example with an alarm. - Sandboxed: no
ATTACH, no extensions, no pragmas that change state or touch files, noTEMPtables, views or triggers (they would escape the size cap). Virtual tables: full-text search (fts3,fts4,fts5) andrtreeonly.
Alarms
Each actor has one pending alarm: set-alarm(at) replaces it. When it is
due, on-alarm runs as an event.
- Alarms survive hibernation, restarts and moves between nodes. In a cluster, if an actor’s node dies, another node fires its alarm a lease period plus grace after it was due, plus up to 10 s, plus restoring the actor (with the defaults, 24 s late at the median and 36 s at worst with nodes next to their bucket, over twelve hours, measured on one lease per node; ADR 0009). An alarm is as durable as the rest of the event that set it.
- They fire at least once. A failed or trapped
on-alarmis retried after 1 s, doubling up to 5 minutes, so make handlers idempotent (record what you did in the database, in the same event). While it is being retried,get-alarmreturns the next attempt’s time. - For several timers, keep them in a table and set the alarm to the
earliest; reschedule from
on-alarm.
Calls between actors
actors.call("class/name", request) sends an HTTP-shaped request to another
actor of the same tenant and waits for the answer.
- The caller’s event stays open meanwhile: other events to the caller wait.
- The callee sees
x-wasmkeep-caller: tenant/class/name(the name percent-encoded:room 1arrives asroom%201), which outside clients cannot forge, so it can trust who is calling. A request without it comes from an outside client and should get only what any client may do. In a cluster that includes calls from a node still talking plain HTTP to one that refuses it (a rollout done in the wrong order, see cluster): treat them like any client’s. - A call that would come back to an actor already waiting in the chain (A
calls B calls A) is refused with
reentrantinstead of deadlocking. - Request bodies are capped at 16 MB (413 when declared larger; a failed
read past it otherwise). Calls between actors cap both ways. A handler
may answer without reading the body: the server reads and discards what
is left (up to the cap, for at most 30 s), so the client can finish
sending it and read the answer. The exception is a client that sent
Expect: 100-continuestraight to the actor’s node, when the handler read nothing of the body: the answer comes without the 100, and the connection closes after it. A client that waited for the 100 has sent nothing and loses nothing. One that sends the body anyway, without waiting, is not read out: its upload is cut off (a broken pipe or a reset), which can cost it the answer, unless the body was small enough to have arrived whole already. Through another node there is no such case: the node that forwards the request sends the 100 itself, and the body is read and discarded like any other.
WebSockets
A client upgrades a GET on the actor’s path. The host owns the socket,
not the instance, so connections stay open while the actor hibernates; the
next message wakes it.
on-websocket-openmay refuse a connection (it is closed with code 1008 and the guest’s reason). Until it accepts, the socket is invisible to the actor’s other events: no broadcast reaches a socket it may refuse. It runs before the client sees the upgrade’s 101, so once a client is connected, every later event of the actor (a broadcast, say) sees its socket.sendqueues a message; a client that does not keep up getsbackpressure(at 256 messages or 4 MiB queued) and that message is dropped for it.connectionslists the actor’s sockets, so broadcasting is a loop.- Limits: messages from clients up to 1 MiB (larger closes the socket with 1009); 1,000 sockets per actor and 50,000 per node (more get 503); a client silent for 25 s is pinged, and closed if it does not answer within 30 s.
- In a cluster, a client may connect to any node: the socket is relayed to the actor’s node. If the actor moves, its sockets close with code 1012 and clients reconnect.
Clusters
Several nodes can share one S3-compatible bucket. Each actor then runs on exactly one node at a time, recorded in the bucket and kept by that node’s own lease; requests that reach another node are forwarded, and each actor’s writes are replicated to the bucket after every event. A node that stops cleanly hands its actors over at once; if a node dies, they move to another node in about 15 to 21 s, with no acknowledged durable write lost. See Running a cluster for how it works, When a node dies, and how it scales for what callers see meanwhile and what adding nodes does, and Deploying for a setup.
Configuration and secrets
An API key, a webhook URL or a feature flag reaches actors through the env
interface, never through the component: the same build runs in staging and
production. The operator writes one file per class, NAME=value per line:
# /etc/wasmkeep/hub.env, readable only by the server's user (chmod 600)
ADMIN_TOKEN=change-me
WEBHOOK_URL="https://hooks.slack.com/services/..."
and serves the class with it: wasmkeep serve --actor hub=hub.wasm --env hub=/etc/wasmkeep/hub.env (also wasmkeep dev). Actors read it with
env::get("ADMIN_TOKEN") (Rust), env.get(...) (TypeScript and Python),
wk_env(...) (C), wk.env(...) (Zig) or wasmkeep.Env(...) (Go), and get nothing for a name that is
not set.
- Per class. A class sees only its own files; every actor of the class
sees the same values.
--envfor a class that is not served is an error (a typo would otherwise leave a class without its token). - On the node.
--envvalues are read once at start (restart to change them), are never written to the data directory or the bucket, and never appear in logs: a malformed line is reported by file and line number only. A file anyone on the machine can read is accepted with a warning. In a cluster, give every node the same files, or keep secrets in the bucket instead (below). - Plain configuration can travel with a deploy. For an app deployed
with
wasmkeep deploy, values that are not secret (URLs, flags) can go with the version instead:wasmkeep deploy --config hub=hub-config.env. They are stored in the bucket in the clear, reach every node with the code, and change without a restart (the next event runs in a new instance;on-initdoes not rerun) (Running a cluster). - Secrets can live in the bucket, sealed.
wasmkeep secrets set --app TENANT/APP --key FILE --class CLASS NAME(the value from stdin) seals a secret with a key the nodes hold (wasmkeep serve --secrets-key FILE). It reaches every node within a few seconds, with no deploy or restart, and a rollback of code never brings an old secret back. A bucket reader sees neither names nor values (Secrets in the bucket, ADR 0016). - Which value wins, by name: the deployed configuration, then the
app’s secrets, then the node’s
--env(logged, never the value). - Required values.
wasmkeep deploy --requires CLASS=NAME,...makes a class refuse its events (503) while one of those names is not set, rather than run without it. - The format is what systemd’s
EnvironmentFile=reads:#comments, an optionalexport, one pair of quotes around the value removed, nothing inside interpreted (no escapes, no$VAR), a name set twice keeps the later value. Names are letters, digits and_; values cannot hold control characters other than tab.
The WASI environment (std::env::var, process.env) stays empty.
What an actor cannot do
Read files, open sockets, see the node’s environment variables, or reach another
tenant’s actors. Many toolchains import wasi:filesystem and
wasi:sockets whether or not the code uses them, so components that do
still load, but those interfaces reach nothing: no directory is opened to
the guest, and every socket and name lookup is refused (tested from inside
a guest). The only way out is outbound HTTP.
Outbound HTTP reaches public addresses only. Loopback, private networks
(10/8, 172.16/12, 192.168/16), IPv6 outside global unicast,
link-local (including cloud metadata at 169.254.169.254), shared and
reserved ranges are refused, also when a host name resolves to them: the
host resolves the name, checks the address and connects to that address.
A refused address fails with destination-ip-prohibited; a name that
resolves only to refused addresses fails like a name that does not resolve
(so refusals cannot map internal names). An operator who wants actors to
reach an internal API allows its range explicitly:
wasmkeep serve --allow-outbound 10.1.0.0/16 (repeatable).
The ports browsers and Cloudflare Workers block are refused as well,
whatever the address: the
Fetch standard’s list of
ports that belong to other protocols, such as mail (25, 465, 587), FTP
(21), SSH (22) and IRC (6667). A crafted HTTP request can drive some of
those servers, as in sending mail through an SMTP relay. Such a request
fails with HTTP-request-denied, before any connection.
wasmkeep serve --allow-outbound-port 587 opens one (repeatable).
HTTPS certificates are checked against the host name in the URL and the
public certificate authorities (Mozilla’s list, built in). An internal API
behind a private CA is trusted with --outbound-ca ca.pem (repeatable),
for every actor on the node.
Each actor may have 16 outbound requests open at once (256 per node), and each step (connect, first byte, between bytes) waits at most 60 s (30 s unless the guest asks otherwise).
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).
When a node dies, and how it scales
What a cluster does when a node stops or dies, what callers see meanwhile, and what adding nodes does and does not do. Running a cluster has the mechanism and the commands; this page is the short answer to “what happens to my application”. It describes the code as it is today; what is planned is on the roadmap.
One node per actor
An actor runs on one node at a time. Its state is one SQLite database with one writer: the node that holds the actor applies its events one at a time, and uploads what each event committed to the bucket. Two nodes running the same actor would be two writers of one history, so a node takes an actor only when the bucket says nobody else holds it (how it works).
A node holds an actor while the actor is in memory. When the actor hibernates (30 s without an event, by default) the node gives it up, and its next event starts it on whichever node that event reaches. A request that reaches another node while the actor is held is forwarded to the holder, once.
A node that stops cleanly
On SIGTERM or Ctrl-C a node:
- stops accepting connections, so a load balancer’s check of
/_wasmkeep/readyfails from then on; - lets requests under way finish, for up to 10 s;
- closes its WebSockets with code 1012;
- lets each actor finish its queued events, for up to 10 s, and gives each actor up, waiting up to 3 s for those releases to reach the bucket;
- writes its own lease
released, within about 6 s.
Each wait ends as soon as its work is done. Together they bound the whole stop at about 29 s. An actor is free as soon as it is given up: its next request starts it on another node at once, after that node has rebuilt the database from the bucket (unless its local copy is already current). Clients whose socket closed with 1012 open it again and reach the new holder.
To empty a node before stopping it, drain it: it hands its actors over while it still forwards requests, and each actor’s pending alarm moves to the new holder’s timer.
A node that dies
A node that is killed, loses power or is cut off from the bucket gives nothing up. The actors it held in memory stay recorded as its own until another node fences it: a peer that has seen its heartbeat unchanged for 6 s writes the fence once 15 s have passed since the dead node’s last heartbeat (the 10 s lease plus a 5 s grace period). The actors are then free, 15 to about 21 s after the dead node’s last heartbeat with the default timings. The wait is what keeps one writer per actor: a node that is only slow, or cut off for a moment, must have stopped acting on its own before anyone else starts (ADR 0020).
Only actors that were in memory on the dead node wait. An actor that had hibernated there was already given up, and its next request starts it on whichever node it reaches. Two things of a hibernated actor still go with the node: the WebSockets it held for the actor, and the actor’s pending alarm, which sat in that node’s timer and now fires late (below).
What callers get meanwhile
For an actor the dead node held, until the fence:
| Caller | Gets |
|---|---|
| An HTTP request that reached the dead node | A broken connection, or none: the load balancer’s check of /_wasmkeep/ready takes the node out |
| An HTTP request that reached another node | 503 with Retry-After: 1. While the dead node’s heartbeat still counts as live (10 s) the request is forwarded and the body says the actor's node did not answer; retry: at once if the connection is refused, after up to 5 s if nothing answers the connection attempt. After that it is not forwarded: the actor's node is not reachable; retry, at once |
actors.call from another actor | The error failed, with a text naming the node (node b is not reachable, or the connection error), or timeout when the caller’s event reaches its wall-clock limit first |
| A WebSocket upgrade | The same 503 with Retry-After: 1 |
| An open WebSocket | No close frame from a node that was killed or lost power. A socket to that node itself breaks at once if the process was killed, and otherwise ends only when the client’s own keepalive or TCP notices. One relayed through a live node over mutual TLS is cut by that node at the latest when it stops trusting the dead one, about 10 s after its last heartbeat (cluster); the relay passes bytes on and writes no close frame of its own (read from the code, not tested). One relayed over plain HTTP (--cluster-plaintext accept) is not cut by the relaying node: it ends when the connection to the dead node breaks, or when the client’s own keepalive notices |
One case is slower and is not a 503. A request forwarded on a connection
the dead node had already accepted, and then never answered, waits 30 s
and gets 504 with the body the actor's node did not answer in time and
no Retry-After. That is on purpose: the request was written to the node,
which may have run it, so the answer does not invite a retry that could
run a request that is not idempotent twice. A WebSocket upgrade forwarded
the same way gets 503 with Retry-After: 1 instead: it carries no body,
and a client opens a socket again anyway.
A 503 does not say whether the request ran: one that was written to the
dead node may have run there. Retry what is safe to repeat, and make the
rest idempotent. What the actor had committed with exec-durable and
acknowledged is in the bucket and survives. A plain exec whose upload
had not finished when the node died can be lost
(replication).
After the fence, the first request for the actor makes the node it
reaches take it: that node rebuilds the database from the bucket, then
runs the request. Clients that retry on 503, with a bound, get through
without doing anything else.
Requests are refused during the window, not held. Holding them until the takeover is on the roadmap.
Alarms
Pending alarms are listed in the bucket, and every node reads the overdue part of that list every 10 s. An alarm whose node died is fired by another node once it is overdue by a lease period plus grace: with the defaults, 24 s late at the median and 36 s at worst with nodes next to their bucket (measured, ADR 0009). Alarms fire at least once, so a handler that had started on the dead node may run again.
What is not interrupted
- Actors on the other nodes. They keep their holder, their sockets and their alarms. Nothing is moved or restarted because a peer died.
- Requests for hibernated actors, wherever they last ran. Their sockets and alarms on the dead node are another matter (above).
- Stateless components. Every node runs its own copy and needs no other node to answer (below).
- The cluster itself. There is no leader and no quorum: the remaining nodes keep working with the bucket, down to one node.
How it scales
Throughput comes from many actors, not from one busy actor. Each actor handles one event at a time; different actors run in parallel, on every node.
- Where an actor runs. The node that receives the first request for an actor nobody holds takes it. A load balancer that hashes the actor’s path sends every request for one actor to the same node, so actors spread over the nodes as their names hash, and little is forwarded.
- Adding a node. Start it on the same bucket and prefix, with its own node id, and add it to the load balancer. With a consistent hash, a share of the actor paths now reach the new node. An actor still in memory elsewhere stays there (the new node forwards to it); once it hibernates, its next request starts it on the new node.
- Removing a node. Drain it, then stop it.
What does not happen today
- No rebalancing by load. Nothing looks at how busy a node is. An
actor in memory stays on its node until it hibernates, until it is moved
with
wasmkeep actor move, until its node is drained withwasmkeep cluster drain, or until its node stops or dies. A move is not a pin: once the actor hibernates, its next event starts it wherever that event lands. - No splitting of one actor. An actor that receives more events than
it can run one at a time fills its queue (1,024 events, then
429). No setting changes that, and a second node does not help. The application uses more, smaller actors: one per room, user, document or device, not one per application. - No placement by region or by rule. Any node of the app may take any of its actors.
Load per node in the metrics, and placement, are on the roadmap.
Stateless components
The component given to wasmkeep serve as its positional argument answers
every path outside /actor/ and /_wasmkeep/ (the node answers that one
itself). It has no database, no lease and nothing in the bucket: each
request gets a new instance on the node that received it,
so any node answers and the nodes do not coordinate. It scales with the
nodes behind the load balancer, within each node’s connection and instance
limits and a 30 s wall clock per request.
It is a file each node is started with, not part of a deploy:
wasmkeep deploy does not change it. Updating it is a restart of each
node in turn, with two versions answering side by side until the last one
has restarted
(how).
Actors or a database?
Actors are strong where state has an owner:
- State per user, room, document or device. Each actor’s data is next to its code, and nothing else writes it.
- Real time. The sockets of a room meet in one actor, in one order.
- Coordination. One event at a time means a counter, a quota or a lock needs no transaction to be exact.
- Scheduled work per entity. An alarm on the actor, delivered at least once, also when its node dies.
They are weak at questions that span every entity: “all orders over 100 this month”, search, reporting, anything a single SQL query over one big table answers. Actors do not see each other’s data, there is no way to list or query across them, and asking each one in turn is a call per actor.
Keep a database for those questions. A component reaches it over outbound
HTTP, the only way out of the sandbox: a database with an HTTP API, or
your own service in front of one. There are no raw sockets, so a
database’s own wire protocol is not reachable from a component. Outbound
HTTP reaches public addresses only unless the operator opens a range with
--allow-outbound, refuses the ports browsers block, and allows 16
requests in flight per actor and 256 per node
(security model). A common split: actors keep the hot,
per-entity state and push a summary to the database when something
changes, and the queries that span everything run there
(use cases).
Cloudflare Durable Objects, celld and Rivet are built on the same model, stateful actors (how they compare).
Key-value and queues
WasmKeep has no KV store or queue built into the platform. The Rust SDK
builds both from actors: a KV shard and a queue are ordinary
actors with an SQLite table, reached with actors.call. They get what
every actor gets (replication, failover, alarms, access rules) and have the
same limits. Why, and what a platform primitive would change, is in
ADR 0015. A working app:
examples/jobs.
The shard and queue classes are written with the Rust SDK. Callers can be Rust or any other SDK: the TypeScript, Python, Go, C and Zig SDKs have the same clients and talk to the same Rust classes.
Those callers need no Rust toolchain: a release carries the two classes
of examples/jobs already built, as kv.wasm (the
ready-made shard) and queue.wasm (its queue class: three attempts, a
short backoff, pull-only except queue/jobs, which pushes to
app/worker), signed like its other files, with the notices of the
libraries compiled into them in THIRD_PARTY_NOTICES-components.txt.
The example’s README builds them from
a clone instead.
# $v: the version of your wasmkeep binary, 0.2.0 or later
curl -fsSLO "https://github.com/WasmKeep/wasmkeep/releases/download/v$v/kv.wasm"
curl -fsSLO "https://github.com/WasmKeep/wasmkeep/releases/download/v$v/queue.wasm"
wasmkeep serve --actor kv=kv.wasm --actor queue=queue.wasm --actor app=your-app.wasm \
--access kv=internal --access queue=internal
Key-value
Deploy a shard class: a component that exports the SDK’s ready-made shard.
#![allow(unused)]
fn main() {
use wasmkeep_sdk::kv::Shard;
wasmkeep_sdk::export!(Shard);
}
Serve it as class kv (--actor kv=..., plus --access kv=internal), and
use it from any other actor:
#![allow(unused)]
fn main() {
use wasmkeep_sdk::kv::{self, PutOptions};
let kv = kv::Client::new("kv", 8); // 8 shards: kv/shard-0 .. kv/shard-7
let v1 = kv.put("user:42", b"Ada")?; // the new version, once durable
let entry = kv.get("user:42")?; // Some(Entry { value, version, expires_at })
kv.compare_and_set("user:42", Some(v1), b"Ada L.")?; // Err(Conflict) if it moved
kv.compare_and_set("user:43", None, b"Bob")?; // only if absent
kv.put_with("session:9", b"...", PutOptions { ttl_ms: Some(60_000), ..PutOptions::default() })?;
kv.delete("user:43")?;
let page = kv.list("user:", None, 100)?; // keys with that prefix, in byte order
}
- Keys are bytes, up to 1 KiB (
&strworks). Values up to what one call carries (16 MB; keep them small). - Shards: a key lives in shard
fnv1a(key) % n. Every caller must use the samen, and changing it needs a migration, so pick it generously (shards cost nothing while idle). - Versions change on every write and are never reused within a shard, even after a delete.
- Expiry: an expired key reads as absent; the shard’s alarm deletes it.
- Read cache:
Client::with_read_cache(ms)servesgetfrom memory in the caller’s instance for up toms; the caller’s own writes through that client update it;get_freshskips it. It is lost with the instance (hibernation, a failed event, a deploy). - For a shard with code of its own (extra checks, other tables), call the
functions in
kv::shardfrom your handler instead of exportingShard.
Queues
Deploy a queue class. Its settings are code, and may depend on the queue’s name:
#![allow(unused)]
fn main() {
use wasmkeep_sdk::queue::{self, Config};
use wasmkeep_sdk::{actors, http};
struct Queue;
fn config() -> Config {
match actors::self_address().name.as_str() {
"emails" => Config::new().consumer("mailer/main"), // push
_ => Config::new(), // pull only
}
}
impl wasmkeep_sdk::Actor for Queue {
fn on_init() -> Result<(), String> {
queue::store::init().map_err(|e| e.to_string())
}
fn on_alarm() -> Result<(), String> {
queue::store::on_alarm(&config()).map_err(|e| e.to_string())
}
}
impl http::Guest for Queue {
fn handle(request: http::IncomingRequest, out: http::ResponseOutparam) {
queue::store::handle(&config(), request, out);
}
}
wasmkeep_sdk::export!(Queue);
}
Producers send to it:
#![allow(unused)]
fn main() {
let emails = wasmkeep_sdk::queue::Client::new("queue/emails");
let id = emails.send(b"welcome ada@example.com")?; // once durable
emails.send_delayed(b"reminder", 3_600_000)?;
emails.send_batch(&[b"a".as_slice(), b"b"])?; // one write, all or none
}
Push (a queue with a consumer): its alarm delivers batches to the consumer, which answers one outcome per message:
#![allow(unused)]
fn main() {
use wasmkeep_sdk::queue::{self, Outcome};
// In the consumer's handler:
if queue::is_delivery(&request) {
queue::handle_delivery(request, out, |_queue, message| {
match send_email(&message.body) {
Ok(()) => Outcome::Ack,
Err(_) => Outcome::Retry(None), // the queue's backoff
}
});
return;
}
}
Pull (any queue): lease, then ack or retry.
#![allow(unused)]
fn main() {
let inbox = wasmkeep_sdk::queue::Client::new("queue/inbox");
for message in inbox.lease(10, 30_000)? { // up to 10, hidden for 30 s
handle(&message.body);
inbox.ack(&message)?; // false if the lease ran out
}
}
Config sets the batch size (10), the visibility timeout (30 s), the
attempts before a message is dead-lettered (5) and the retry backoff (1 s
doubling to 5 min). Client::stats counts ready, delayed, in-flight and
dead messages; Client::dead_letters lists the dead ones with the reason.
TypeScript
The TypeScript SDK (@wasmkeep/sdk) has the client side only: the
kv client, the queue client and queue.handleDelivery for push
consumers. The shard and queue classes stay Rust: deploy them as the
components from examples/jobs, kv
(examples/jobs/kv, the SDK’s ready-made shard)
and queue (examples/jobs/queue, or your own
Rust queue class when you need other settings), built from a clone or
downloaded from a release, and call them from a
TypeScript actor. The wire protocol, the shard hash and the status codes
are the same byte for byte in every SDK, so callers in any language share
the same data. examples/ts-jobs is the jobs app in
TypeScript.
import { defineActor, kv, queue } from "@wasmkeep/sdk";
const store = new kv.Client("kv", 8); // the same shard count as every other caller
const emails = new queue.Client("queue/emails");
const inbox = new queue.Client("queue/inbox");
export default defineActor({
fetch(req) {
// A push consumer: one outcome per message.
if (queue.isDelivery(req)) {
return queue.handleDelivery(req, (_queue, message) => "ack"); // or "retry", or { retryAfterMs: 500 }
}
// Calls to shards and queues run in an event, never at module level.
const v1 = store.put("user:42", "Ada"); // the new version, once durable
const entry = store.get("user:42"); // { value: Uint8Array, version, expiresAt? } or undefined
store.compareAndSet("user:42", v1, "Ada L."); // throws KvError (kind "conflict") if it moved
store.compareAndSet("user:43", undefined, "Bob"); // only if absent
store.putWith("session:9", "...", { ttlMs: 60_000, durable: true });
store.deleteIf("user:43", 7); // only at that version
const page = store.list("user:", undefined, 100); // Uint8Array keys, in byte order
const id = emails.send("welcome ada@example.com");
emails.sendDelayed("reminder", 3_600_000);
emails.sendBatch(["a", "b"]); // one write, all or none
for (const message of inbox.lease(10, 30_000)) {
inbox.ack(message); // false if the lease ran out
}
return "ok\n";
},
});
The names and semantics follow the Rust ones (getFresh,
withReadCache(ms), stats(), deadLetters(limit), settle(items)),
with these differences:
- Numbers, not 64-bit integers. Versions, ids, times and counts are
numbers. The wire still carries 64-bit integers; a version or id past 2^53 - 1 (which a shard or queue does not reach in practice) makes the reply malformed (KvError/QueueErrorkindrefused, status 200) instead of being rounded. - Errors are exceptions:
KvError(kind:conflict,key-too-long,storage,call,refused, withstatusforrefused) andQueueError(storage,call,refused), with the Rust messages. A negative or fractional count, delay or limit throws aRangeErrorbefore any call. - Keys and values are strings (sent as UTF-8, so the same key reaches
the same shard from Rust) or
Uint8Arrays; values come back asUint8Array. - A throwing delivery handler makes
fetchanswer 500: the queue retries the whole batch with its backoff.
Python
The Python SDK (sdks/python, module wasmkeep) has the same client
side: kv, queue and queue.handle_delivery, talking to the same Rust
shard and queue classes (deploy them as in TypeScript) with
the same protocol, hash and statuses. examples/py-jobs
is the jobs app in Python.
from wasmkeep import Actor, kv, queue
store = kv.Client("kv", 8) # the same shard count as every other caller
emails = queue.Client("queue/emails")
inbox = queue.Client("queue/inbox")
class App(Actor):
def fetch(self, req):
# A push consumer: one outcome per message.
if queue.is_delivery(req):
return queue.handle_delivery(req, lambda _queue, m: "ack") # or "retry", or queue.RetryAfter(500)
# Calls to shards and queues run in an event, never at module level.
v1 = store.put("user:42", "Ada") # the new version, once durable
entry = store.get("user:42") # Entry(value=b"Ada", version, expires_at) or None
store.compare_and_set("user:42", v1, "Ada L.") # raises KvError (kind "conflict") if it moved
store.compare_and_set("user:43", None, "Bob") # only if absent
store.put_with("session:9", "...", ttl_ms=60_000, durable=True) # expect="any" | "absent" | a version
store.delete_if("user:43", 7) # only at that version
page = store.list("user:", None, 100) # bytes keys, in byte order
message_id = emails.send("welcome ada@example.com")
emails.send_delayed("reminder", 3_600_000)
emails.send_batch(["a", "b"]) # one write, all or none
for message in inbox.lease(10, 30_000):
inbox.ack(message) # False if the lease ran out
return "ok\n"
The names and semantics follow the Rust ones (get_fresh,
with_read_cache(ms), stats(), dead_letters(limit), settle(items)),
with these differences:
- Integers are exact. Versions, ids, times and counts are Python
ints, so nothing is rounded. Since aninthas no bounds, a count, delay, limit or version outside what Rust’su32oru64would hold raisesValueErrorbefore any call. - Errors are exceptions:
KvError(kind:conflict,key-too-long,storage,call,refused, withstatusforrefused) andQueueError(storage,call,refused), with the Rust messages asstr(e). - Options are keyword arguments:
put_with(key, value, expect=, ttl_ms=, durable=), whereexpectis"any","absent"or a version.list(prefix, after, limit)defaults to every key (b""), from the start, 100 at a time. - Outcomes are
"ack","retry"orqueue.RetryAfter(ms); records (Entry,Message,Settlement,Stats,DeadLetter) are frozen dataclasses with snake_case fields (expires_at,enqueued_at,in_flight,failed_at). - Keys and values are
str(sent as UTF-8, so the same key reaches the same shard from Rust) orbytes; values come back asbytes. - A raising delivery handler makes
fetchanswer 500: the queue retries the whole batch with its backoff.
Go
The Go SDK (for TinyGo) has the client side, like
TypeScript: the kv client, the queue client and
queue.HandleDelivery for push consumers, talking to the Rust shard and
queue classes from examples/jobs with the same
protocol, hash and status codes. The clients send through a call.Caller:
wasmkeep.Caller, the SDK’s own call to another actor
(examples/go-jobs is the jobs app in Go).
import (
"errors"
"github.com/wasmkeep/wasmkeep/sdks/go/kv"
"github.com/wasmkeep/wasmkeep/sdks/go/queue"
"github.com/wasmkeep/wasmkeep/sdks/go/wasmkeep"
)
store := kv.New("kv", 8, wasmkeep.Caller)
v1, err := store.Put("user:42", []byte("Ada")) // the new version, once durable
entry, err := store.Get("user:42") // *kv.Entry, nil if absent
_, err = store.CompareAndSet("user:42", kv.Version(v1), []byte("Ada L."))
if errors.Is(err, kv.ErrConflict) { /* it moved */ }
_, err = store.CompareAndSet("user:43", kv.Absent(), []byte("Bob"))
_, err = store.PutWith("session:9", []byte("..."), kv.PutOptions{TTLMs: 60_000})
keys, err := store.List("user:", 100) // in byte order; ListAfter pages
emails := queue.New("queue/emails", wasmkeep.Caller)
id, err := emails.Send([]byte("welcome ada@example.com"))
ids, err := emails.SendBatch([][]byte{[]byte("a"), []byte("b")})
inbox := queue.New("queue/inbox", wasmkeep.Caller)
messages, err := inbox.Lease(10, 30_000)
for _, m := range messages {
acked, err := inbox.Ack(m) // false if the lease ran out
}
// A push consumer, in the Fetch hook:
if queue.IsDelivery(req.Method, req.Path) {
reply := queue.HandleDelivery(queue.CallerFrom(req.Headers), req.Body(),
func(_ string, m queue.Message) queue.Outcome { return queue.Ack() }) // or queue.Retry(), queue.RetryAfter(500)
return wasmkeep.Bytes(reply.Status, reply.ContentType, reply.Body)
}
The names and semantics follow the Rust ones (GetFresh,
WithReadCache(ms), Retry, RetryAfter, Settle, Stats,
DeadLetters(limit)), with these differences:
- Errors are values:
*kv.Error(Kind:Conflict,KeyTooLong,Storage,Call,Refused, withStatusforRefused) and*queue.Error(Storage,Call,Refused), with the Rust messages.errors.Is(err, kv.ErrConflict)matches by kind;errors.Asgives the fields. - Zero values are the defaults.
kv.PutOptions{}is a durable, unconditional write without expiry:Local: trueasks for a local write (Rust’sPutOptions::local()), andTTLMs: 0means no expiry (so a zero TTL cannot be asked for). The zerokv.Expectiskv.Any(), the zeroqueue.Outcomeisqueue.Ack(). - Keys are strings (any bytes, so the same bytes reach the same shard
from every SDK); values and bodies are
[]byte.ListandListAfterreplace Rust’sOptionforafter. - The delivery helper does not touch HTTP: it takes the
x-wasmkeep-callerheader (""when absent) and the body, and returns the status, content type and body to answer. A handler that panics traps the event: the queue retries the whole batch with its backoff. - For TinyGo: the clients avoid reflection-heavy packages (no
fmt), so they suit TinyGo’swasip2target, and their unit tests run with plaingo test.
C
The C SDK (sdks/c, declared in wasmkeep_kv.h, which
wasmkeep.h includes) has the same client side as TypeScript: the wk_kv
client, the wk_queue client and wk_queue_handle_delivery for push
consumers, speaking the same protocol to the same Rust classes.
examples/c-jobs is the jobs app in C. Compile
wasmkeep_kv.c, wasmkeep_wire.c and wasmkeep_workflow.c with
wasmkeep.c (the examples’ build.sh do).
#include "wasmkeep.h"
static const wk_kv store = {.class_name = "kv", .shards = 8}; /* or wk_kv_client("kv", 8) */
static const wk_queue emails = {.address = "queue/emails"};
wk_kv_error err;
uint64_t v1;
wk_kv_put(&store, wk_str("user:42"), wk_str("Ada"), &v1, &err); /* false on failure: err.kind, err.message */
wk_kv_entry entry;
if (wk_kv_get(&store, wk_str("user:42"), &entry, &err) == 1) { /* 1 found, 0 absent, -1 failed */
/* entry.value (wk_bytes), entry.version, entry.expires_at (0: never) */
wk_kv_entry_free(&entry);
}
wk_kv_compare_and_set(&store, wk_str("user:42"), &v1, wk_str("Ada L."), NULL, &err); /* WK_KV_CONFLICT if it moved */
wk_kv_compare_and_set(&store, wk_str("user:43"), NULL, wk_str("Bob"), NULL, &err); /* only if absent */
wk_kv_put_options opts = {.has_ttl = true, .ttl_ms = 60000}; /* zeroed: no condition, durable */
wk_kv_put_with(&store, wk_str("session:9"), wk_str("..."), &opts, NULL, &err);
wk_kv_keys page;
if (wk_kv_list(&store, wk_str("user:"), NULL, 100, &page, &err)) wk_kv_keys_free(&page);
uint64_t id;
wk_queue_error qerr;
wk_queue_send(&emails, wk_str("welcome ada@example.com"), &id, &qerr);
/* A push consumer: one outcome per message; return false to fail the batch. */
static bool work(const char *queue, const wk_queue_message *m, wk_queue_outcome *outcome, void *ctx) {
*outcome = wk_queue_ack_outcome(); /* or wk_queue_retry_outcome(), wk_queue_retry_after(500) */
return true;
}
wk_response wk_fetch(wk_request *req) {
if (wk_queue_is_delivery(req)) return wk_queue_handle_delivery(req, work, NULL);
return wk_reply(200, "ok\n");
}
The names follow the Rust ones (wk_kv_get_fresh, wk_kv_with_read_cache,
wk_kv_delete_if, wk_queue_send_delayed, wk_queue_send_batch,
wk_queue_lease, wk_queue_ack, wk_queue_retry, wk_queue_settle,
wk_queue_get_stats, wk_queue_get_dead_letters), with these
differences:
- Errors are values: each call returns
false(or-1) and fills an optionalwk_kv_error/wk_queue_errorwith a kind (WK_KV_CONFLICT,WK_KV_KEY_TOO_LONG,WK_KV_STORAGE,WK_KV_CALL,WK_KV_REFUSEDwithstatus;WK_QUEUE_STORAGE,WK_QUEUE_CALL,WK_QUEUE_REFUSED) and the Rust message. A refusal’s message keeps at most 400 bytes of the answer, unchanged (not made valid UTF-8). - Ownership is explicit: what comes back with memory (an entry, keys,
messages, dead letters) is freed with its
_freefunction; returned bytes are NUL-terminated as well. Clients borrow their class name or address.wk_queue_send_batchandwk_queue_settlewrite into arrays the caller sizes;settlemarks items the queue’s reply leaves out as not current. - Keys and values are
wk_bytes(wk_strfor a C string), 64-bit integers areuint64_t, and options are optional pointers (NULL: an absent key forcompare_and_set, the queue’s backoff forretry). - A delivery handler returns
falseto fail the batch (500, retried with the queue’s backoff), where Rust panics and TypeScript throws.
wk_response_header(&r, "etag", v) adds a header to a response (the
jobs app’s etag).
Zig
The Zig SDK (sdks/zig) wraps the C clients, with Zig
errors and slices: wk.kv.Client, wk.queue.Client and
wk.queue.handleDelivery. examples/zig-jobs is
the jobs app in Zig.
const wk = @import("wasmkeep");
const store = wk.kv.Client.init("kv", 8); // a comptime constant is fine
const emails = wk.queue.Client.init("queue/emails");
fn example() !void {
const v1 = try store.put("user:42", "Ada"); // the new version, once durable
if (try store.get("user:42")) |found| { // ?Entry: value, version, expires_at
var entry = found;
defer entry.deinit();
}
_ = try store.compareAndSet("user:42", v1, "Ada L."); // error.Conflict if it moved
_ = try store.putWith("session:9", "...", .{ .ttl_ms = 60_000 });
var page = try store.list("user:", null, 100); // page.len(), page.at(i)
defer page.deinit();
_ = try emails.send("welcome ada@example.com");
}
fn work(queue: []const u8, message: wk.queue.Message) wk.queue.Outcome {
_ = queue;
_ = message;
return .ack; // or .retry, or .{ .retry_after = 500 }
}
pub fn fetch(req: *wk.Request) !wk.Response {
if (wk.queue.isDelivery(req)) return wk.queue.handleDelivery(req, work);
return wk.reply(200, "ok\n", .{});
}
The differences from Rust are the C ones, in Zig terms:
- Errors:
wk.kv.Error(Conflict,KeyTooLong,Storage,Call,Refused) andwk.queue.Error(Storage,Call,Refused); the message and status of the last failure arewk.kv.lastError()/lastStatus()(and the same inwk.queue). - Ownership: entries, key pages, leased messages and dead letters have
deinit;sendBatchandsettletake an allocator for the slice they return. - A delivery handler returns an
Outcome, or an error union of one: an error makeshandleDeliveryreturn it, sofetchanswers 500 and the queue retries the batch with its backoff.
Consistency and failure
- Per key, strongly consistent. A shard handles one operation at a time, so reads see the latest write and compare-and-set is exact. Only the optional read cache serves older values, up to its age.
- Durable when acknowledged.
put,deleteandsendreturn once the write is in the bucket (fsynced on a node without one), unless a local write is asked for (PutOptions::local()). - No transactions across keys, across shards, or between a KV write or a send and the caller’s own database. If the caller’s event fails after calling, the call’s write stays: treat it as a side effect, like outbound HTTP.
- A failed call is ambiguous.
Error::Callon a write or a send means it may have applied. Re-read before retrying a compare-and-set; a send retried after a lost reply may queue the message twice. - At-least-once delivery. A message may be delivered again after a
lost ack, a lease that ran out, a failed push call or a failover, with
the same
idand a higherattempt. Record the ids you handled when a duplicate would matter, in the same event as the work. - Stale acks are ignored. An ack or retry names its lease
(
id,attempt); once the message is leased again, the old one does nothing. - Ordering: oldest visible first; FIFO only with one consumer, batches of one and no retries.
- Failover: a shard or queue is one actor, unavailable while another node takes it over: 15 to about 21 s after its node’s last heartbeat, then a restore (how; on actors, before ADR 0020: 15.5 to 17.0 s at the median with nodes next to their bucket, and 18.2 s on node leases, measured). Pending push deliveries and expiry are alarms, fired after a failover as for any actor (Alarms).
Limits
- Throughput per shard and per queue is about one durable write per bucket round trip. A durable write took 54 ms at the median with nodes next to an OVHcloud bucket (49 ms in a later run), 81 ms from a desktop to the same store and 587 ms from a desktop to R2 (measured): roughly 18 writes a second next to the bucket, under 2 from a desktop to R2. Those figures come from the actor soak; shards and queues were not benchmarked on their own. Spread hot keys over more shards, split a busy queue into several (by key hash), batch sends, or use local writes where losing the last write in a node crash is acceptable.
- Each operation is one call, which holds the caller’s event while it runs. A push delivery holds the queue’s event while the consumer runs: keep consumers short, or pull.
- No re-entry: a consumer must not call the queue that is delivering to it (the call is refused); a shard must not call itself through KV.
- Outside clients get
403from shards and queues (they answer onlyactors.call). Serve their classes with--access CLASS=internalas well, so outside requests never create an actor.
If these limits get in the way, say so in an issue: a platform primitive
can replace the actors behind the same Client API
(ADR 0015).
Workflows
Work that spans minutes to days and must survive restarts and failover:
reserve stock, wait for approval, charge, notify, retrying each part on
its own. wasmkeep_sdk::workflow runs it on ordinary actors, one actor per
workflow instance, with no platform feature behind it
(ADR 0019). The Rust and
TypeScript SDKs have it (TypeScript below); Go, Python, C
and Zig have the client side (Go, Python, C,
Zig).
Writing a workflow
A workflow is a function. The runner calls it from the top every time the instance moves forward; inside it, three calls record what happened:
#![allow(unused)]
fn main() {
use wasmkeep_sdk::workflow::{Run, Stop};
fn order(wf: &mut Run, input: &[u8]) -> Result<Vec<u8>, Stop> {
let reservation: String = wf.step_keyed("reserve", |key| stock::reserve(input, key))?;
wf.sleep("cool-off", 60_000)?;
if wf.wait_for_event("approved", 86_400_000)?.is_none() {
return Err(Stop::fail("not approved in time"));
}
let _: String = wf.step_keyed("charge", |key| payments::charge(&reservation, key))?;
Ok(b"shipped".to_vec())
}
}
step(name, f)callsfand, once it returnsOk, records its result (String,Vec<u8>,u64,i64,boolor(): convert other types to one of these, or implementStored); every later run gets the recorded result without callingf. Until thenfmay run again: an error fromfis retried with backoff (1 s doubling, 5 attempts by default), and an attempt cut before its result is recorded runs again (rule 2 below). When the attempts run out the workflow fails, or, withtry_step, gets the error to handle.sleep(name, ms)andsleep_until(name, at)wait. The instance costs nothing meanwhile: its alarm brings it back.wait_for_event(name, timeout_ms)waits for an event delivered withClient::send_event, and returns its payload, orNoneafter the timeout. Each wait takes the oldest event of that name not taken yet.- The function returns the workflow’s output, or
Err(Stop::fail(..))to fail it.?passes the runner’s ownStop(waiting) through.
Two rules
- Between steps, the same decisions every time. The function replays from the top, so it must reach the same steps in the same order given the same input and recorded results. Put anything that varies inside a step: the time, random numbers, reads of data, HTTP calls. Steps are identified by their position, so a loop may repeat a step name; a run that reaches a different step at a recorded position fails the workflow (“the code changed the order of its steps”) rather than mixing two histories. Deploying new code onto running workflows is safe when it only adds steps after the recorded ones.
- A step may run more than once. Its result is recorded after
freturns, so a node failure or a cut in between runs it again. Make steps idempotent, or usestep_keyed, which gives the step an idempotency key for the service it calls: unique to the step and to this run of the instance.
The workflow class
The workflow is served as a class of its own, which uses its actors’ alarm, so it does nothing else:
#![allow(unused)]
fn main() {
use wasmkeep_sdk::workflow::{self, Config};
struct Orders;
impl wasmkeep_sdk::Actor for Orders {
fn on_init() -> Result<(), String> {
workflow::store::init().map_err(|e| e.to_string())
}
fn on_alarm() -> Result<(), String> {
workflow::store::on_alarm(&Config::new(), order);
Ok(())
}
}
impl wasmkeep_sdk::http::Guest for Orders {
fn handle(r: wasmkeep_sdk::http::IncomingRequest, o: wasmkeep_sdk::http::ResponseOutparam) {
workflow::store::handle(&Config::new(), r, o)
}
}
wasmkeep_sdk::export!(Orders);
}
Config sets the attempts, the backoff, how long a finished instance is
kept (retention_ms, 7 days by default) and how much of an event a run
spends on new steps before continuing in a new event (20 s of the 30 s).
Serve the class with --access orders=internal: only other actors start
and query workflows (the runner also refuses outside requests, 403).
To try a client in another language without a Rust toolchain, a release
carries the example’s workflow already built: workflows-orders.wasm
(the orders class of examples/workflows) and
workflows-ledger.wasm (the ledger class its steps call, so it needs
both), signed like the release’s other files, with the notices of the
libraries compiled into them in THIRD_PARTY_NOTICES-components.txt.
The example’s README builds them
from a clone instead.
# $v: the version of your wasmkeep binary, 0.2.0 or later
for f in workflows-orders.wasm workflows-ledger.wasm; do
curl -fsSLO "https://github.com/WasmKeep/wasmkeep/releases/download/v$v/$f"
done
wasmkeep serve --actor orders=workflows-orders.wasm --actor ledger=workflows-ledger.wasm \
--actor app=your-app.wasm --access orders=internal
Starting and following instances
From any other actor:
#![allow(unused)]
fn main() {
use wasmkeep_sdk::workflow::Client;
let orders = Client::new("orders");
orders.start("order-42", b"item=book")?; // idempotent while kept
let status = orders.status("order-42")?; // state, wake time, output or error
orders.send_event("order-42", "approved", b"")?;
orders.cancel("order-42")?;
}
startonly records: the instance runs in its own event right after, never inside the caller’s call. A step may therefore call back the actor that started it.statusreportsRunning,Waiting(until when, for which events),Completed(with the output),Failed(with the error) orCancelled, and how many positions are recorded.send_eventandcancelto an id that was never started answerError::Unknownand leave nothing behind; an event to a finished instance answersError::Finished.cancelstops the instance at once; a step running at that moment finishes first.- After its retention, a finished instance deletes itself. Its id is then
unknown, and a
startwith it is a new run (new idempotency keys).
Go
The Go SDK (for TinyGo) has the client only: package
workflow starts and follows instances of a workflow class written with
the Rust runner, over the same protocol (the wasmkeep-workflow/v1
routes). As with its kv and queue clients, it sends
through wasmkeep.Caller.
import (
"errors"
"github.com/wasmkeep/wasmkeep/sdks/go/wasmkeep"
"github.com/wasmkeep/wasmkeep/sdks/go/workflow"
)
orders := workflow.New("orders", wasmkeep.Caller)
status, err := orders.Start("order-42", []byte("item=book")) // *workflow.Status
status, err = orders.Status("order-42") // State, WakeAt, WaitingFor, Output, Error, Positions
err = orders.SendEvent("order-42", "approved", nil)
status, err = orders.Cancel("order-42")
if errors.Is(err, workflow.ErrUnknown) { /* never started, or past its retention */ }
if errors.Is(err, workflow.ErrFinished) { /* takes no more events */ }
WakeAt is 0 when there is none, Output nil when there is none (an
empty output is non-nil), Error empty when there is none. Errors are
*workflow.Error with the Rust SDK’s kinds and messages (ErrUnknown,
ErrFinished, ErrCall, ErrRefused). examples/go-jobs
drives the orders class of examples/workflows
with the same order routes as its Rust app; that class is also
in a release, already built.
Python
The Python SDK (sdks/python, module wasmkeep.workflow) has the
client only: it starts and follows instances of a workflow class
written with the Rust runner, over the same protocol, as
its key-value and queue clients do.
examples/py-jobs drives the orders class of
examples/workflows this way (the class is also
in a release, already built).
from wasmkeep import Actor, workflow
orders = workflow.Client("orders")
class App(Actor):
def fetch(self, req):
orders.start("order-42", "item=book") # a Status; idempotent while kept
orders.send_event("order-42", "approved", b"")
try:
status = orders.status("order-42")
except workflow.WorkflowError as e:
if e.kind == "unknown": # never started, or past its retention
return "no such order\n"
raise
if status.state == "completed":
return status.output # bytes
return f"{status.state}, waiting for {status.waiting_for}\n"
The names and semantics are the Rust ones, with these differences:
Statusis a frozen dataclass:stateis a string ("running","waiting","completed","failed"or"cancelled", also inworkflow.STATES),wake_atanintorNone,waiting_fora list of names,outputbytesorNone,errora string orNone,positionsanint;is_finishedis a property.- Errors are exceptions:
WorkflowErrorwithkindunknown(404: no such instance),finished(409: an event to a finished instance),call(the call failed: the instance may or may not have recorded it) orrefused(any other answer, withstatus), worded as Rust’sDisplay. - Inputs and payloads are
str(sent as UTF-8) orbytes, and default to empty.
C
The C SDK (sdks/c, declared in wasmkeep_workflow.h, which
wasmkeep.h includes; compile wasmkeep_workflow.c with the other SDK
files) has the client side only: the workflow class itself runs on the
Rust runner. It speaks the same protocol as Client, so it starts and
follows the same instances.
static const wk_workflow orders = {.class_name = "orders"}; /* or wk_workflow_client("orders") */
wk_workflow_status s;
wk_workflow_error err;
if (!wk_workflow_start(&orders, wk_str("order-42"), wk_str("item=book"), &s, &err))
return wk_reply(502, "%s\n", err.message);
wk_workflow_status_free(&s);
if (wk_workflow_get_status(&orders, wk_str("order-42"), &s, &err)) {
/* s.state (WK_WORKFLOW_WAITING, ...), s.wake_at (0: none), s.waiting_for[0 .. s.waiting_count),
* s.has_output and s.output, s.error (len 0: none), s.positions */
wk_workflow_status_free(&s);
}
wk_workflow_send_event(&orders, wk_str("order-42"), wk_str("approved"), wk_str(""), &err);
wk_workflow_cancel(&orders, wk_str("order-42"), NULL, &err); /* NULL: the status is not wanted */
- Each call returns
falseon failure and fills an optionalwk_workflow_error:WK_WORKFLOW_UNKNOWN,WK_WORKFLOW_FINISHED,WK_WORKFLOW_CALL(the call failed; the instance may or may not have recorded it) orWK_WORKFLOW_REFUSEDwithstatus(200 for a malformed status), and the Rust message. wk_workflow_get_statusis Rust’sstatus(wk_workflow_statusis the type). Ids, event names and payloads arewk_bytes; a status is freed withwk_workflow_status_free, and its bytes are NUL-terminated.
The jobs app in C (examples/c-jobs) has the
order routes of examples/workflows/app.
Zig
The Zig SDK wraps the C client as wk.workflow
(workflow.zig), with Zig errors:
const orders = wk.workflow.Client.init("orders"); // a comptime constant is fine
fn example() !void {
var status = try orders.start("order-42", "item=book");
defer status.deinit();
// status.state (.running, .waiting, ...), status.wake_at (?u64),
// status.waitingCount() / waitingFor(i), status.output (?[]const u8),
// status.failure (?[]const u8), status.positions
try orders.sendEvent("order-42", "approved", "");
var cancelled = try orders.cancel("order-42");
cancelled.deinit();
}
wk.workflow.Error is Unknown, Finished, Call or Refused; the
message and status of the last failure are wk.workflow.lastError() and
lastStatus(). The jobs app in Zig
(examples/zig-jobs) has the same order
routes.
What is guaranteed
- Every state reported (started, a step’s result, a sleep’s wake time, an
event received, completed, failed, cancelled) is written with
exec-durable: it survives any node failure. - While an instance is not finished, its alarm is set, so a trap, a cut or a failover never leaves it stuck: the next attempt comes from the alarm, after the step’s backoff. Runs that keep ending without the function returning (a trap outside any step, a replay over its CPU budget) back off too, and fail the instance after twice its attempts.
- A database error inside the runner is retried, never turned into a failed workflow.
- Every event makes progress: a run spends
run_budget_mson new steps and continues in a new event, but always runs at least one. - One event at a time per instance: no two runs of the same workflow race.
TypeScript
The same runner, tables and protocol in @wasmkeep/sdk, so a Rust client
follows a TypeScript workflow and the other way round. A waiting run
stops by throwing a Stop (let it through, or rethrow it from a
catch-all: workflow.isStop(e)), and step results are recorded as JSON:
import { env, http, workflow } from "@wasmkeep/sdk";
export default workflow.define(
(wf, input) => {
const reservation = wf.step("reserve", (key) => {
const r = http.fetch(env.get("RESERVE_URL") ?? "", {
method: "POST",
headers: { "idempotency-key": key },
});
// Any status is a response: throw on failure, so it is retried
// rather than recorded.
if (r.status !== 200) throw new Error(`reserve answered ${r.status}`);
return r.text();
});
wf.sleep("cool-off", 60_000);
if (wf.waitForEvent("approved", 86_400_000) === undefined) {
throw workflow.fail("not approved in time");
}
return `shipped: ${reservation}`;
},
// A function, read on each event: module code runs at build time, when
// the class's configuration is not there yet.
() => ({ maxAttempts: Number(env.get("ATTEMPTS") ?? 5) }),
);
wf.step(name, (key) => ...)passes the step’s idempotency key; an exception thrown from it is retried.wf.tryStepreturns{ ok }or{ error, attempts }instead of failing the workflow.wf.sleep,wf.sleepUntilandwf.waitForEvent(the payload as bytes, orundefinedafter the timeout) work as in Rust.new workflow.Client("orders")hasstart,status,sendEventandcancel; errors areworkflow.WorkflowError(kind:unknown,finished,call,refused).- Steps that call other services use
http.fetch, the SDK’s synchronous outbound HTTP, on either engine. A response whose body breaks before its end (the connection closes short of itsContent-Lengthor inside a chunk, a reset, a timeout between bytes) throws aFetchErrornaming the incomplete body, so a step that checks only the status fails (retried while it has attempts left) rather than recording a cut answer. A body longer thanlimitis not an error: its firstlimitbytes are kept. The Rust SDK’shttp::fetchdoes the same, returning an error.
examples/ts-workflows is the order of
examples/workflows in TypeScript, started and followed by the Rust app.
Limits
- A step’s work fits in one event: under 30 s of wall clock and the
event’s 50 ms of CPU, less what replay used. Longer work goes to
something that reports back with
send_event. - Replay costs about 1.5 µs a recorded step in every event (a Rust workflow: 7.7 ms for a run over 5,000 steps, measured). Thousands of steps are fine; tens of thousands approach the event’s CPU budget.
- Large inputs cost CPU in TypeScript on QuickJS, the default engine,
which interprets: the input is read in every event, and a step that
sends it as a request body copies it again. In the example, a 64 KiB
input sent as a body fits in an event; at 256 KiB every run goes over
the CPU budget before its first step, and the instance fails after
twice its attempts (about a minute with the default backoff), saying
so. StarlingMonkey handled 256 KiB. Keep large data out of the input
(store it, and pass its key), or raise
--cpu-budget-ms. - No list of instances: an instance is found by its id. Keep a registry (a key-value prefix) if you need one.
- Failover: an instance is unavailable until a peer frees its dead node’s actors (15 to about 21 s after that node’s last write, how) and its actor is restored, and its alarm fires late, as for any actor: 24 s at the median and 36 s at worst with nodes next to their bucket, over twelve hours (measured).
The example in examples/workflows runs an order
through every case above.
Where to run it
WasmKeep is a long-lived process that keeps actors, leases, WebSockets and timers in memory, writes many small transactions to local SQLite files, holds three open files per resident actor, and reserves a large virtual address space for its instance pool. That shapes where it runs well.
| Option | Pros | Cons | Use it for |
|---|---|---|---|
| Dedicated server (bare metal) | Best price per performance; local NVMe makes fsync fast and predictable; no noisy neighbours | You handle hardware failure and OS upkeep; slower to resize | Production on a budget, clusters of three or more, benchmarks you publish |
| Virtual machine | Easy to create, resize and snapshot; private networks built in | Noisy neighbours (our shared-VM benchmark runs vary by up to ±30%); network-attached disks add fsync latency: prefer instance-local NVMe or fast volumes | The default, from one VPS to a three-node cluster |
| Container (Docker, Compose, Kubernetes) | Reproducible packaging and rollout; a distroless image and a three-node Compose example in deploy/ | One more layer; see the rules below | Teams that standardise on containers |
| Serverless containers (Cloud Run, Fargate, Lambda) | No servers to manage | Scale-to-zero kills resident actors and drops leases; instances are ephemeral, with no stable node identity; CPU may be throttled between requests; WebSockets and alarms fight the model | Not recommended |
A container is packaging, not isolation: actors are already sandboxed by WebAssembly. Isolating tenants who may be hostile needs a stronger tier (a process or microVM per tenant), which WasmKeep does not have yet (security model).
Rules that apply everywhere
- A real disk for
--data-dir: local NVMe or a fast volume; never a container’s overlay filesystem or a network file system (SQLite relies on local locking andfsync). - Open files: give the process a high limit (
LimitNOFILE=65536with systemd,--ulimit nofile=65536with Docker). WasmKeep raises its soft limit to the hard one and caps resident actors to fit (monitoring). - Virtual memory: no
LimitASorulimit -v; do not useMemoryDenyWriteExecute(Wasmtime maps compiled code executable). A memory limit on resident memory (cgroups) is fine. - Transparent huge pages need nothing: WasmKeep turns them off for
its own process on Linux, which halves memory per actor where the
kernel sets them to
always(monitoring). Leave the system setting alone for other software on the machine. - A stable node identity in a cluster: one
--node-idper machine (a StatefulSet on Kubernetes). - A private network between nodes, in the same region as the bucket; only the load balancer reaches them (front door).
- Local disk durability: on one node, the local disk is the database, so back it up (one server); in a cluster the bucket is the source of truth and a node’s disk can be lost.
What is measured so far
The numbers in Benchmarks come from one workstation and a shared cloud VM: good for comparing commits, languages and runtimes, not for capacity claims. A three-node soak ran against R2 and OVHcloud Object Storage, from a desktop and from three VPS next to the bucket (results): nothing lost over 36 kills in four runs. Put the nodes in the same region as the bucket: a durable write waits for one PUT (54 ms next to it, 587 ms from a desktop to R2), and a failover for a restore of many GETs. The suite has not yet run on dedicated hardware.
One server (VPS)
WasmKeep on one Linux machine, behind a reverse proxy that terminates TLS, run by systemd. This is the right setup for most projects: one node serves thousands of requests per second (see benchmarks), and adding a bucket gives it continuous off-machine backups.
Pre-alpha. Fine for side projects and staging; keep a backup of any data you care about.
1. Install the binary
Each release on GitHub
carries, for x86_64 and aarch64 Linux (glibc 2.28 or newer: RHEL 8,
Debian 10, Ubuntu 20.04 and later), a tarball with the binary and its
third-party notices, a CycloneDX SBOM, SHA256SUMS, and a Sigstore bundle
for each file. CI builds and signs them (.github/workflows/release.yml);
no key is involved, the signature’s certificate names the workflow and
the tag. The example components (counter.wasm and the others) are
attached too, with THIRD_PARTY_NOTICES-components.txt, the notices of
the libraries compiled into them, and the C and Zig SDKs as archives; each
has its own bundle, checked the same way. To check a download with cosign 3
(the version that signs them), here for v0.3.0:
f=wasmkeep-0.3.0-x86_64-unknown-linux-gnu.tar.gz
for file in "$f" "$f.sigstore.json"; do
curl -fsSLO "https://github.com/WasmKeep/wasmkeep/releases/download/v0.3.0/$file"
done
cosign verify-blob --bundle "$f.sigstore.json" \
--certificate-identity https://github.com/WasmKeep/wasmkeep/.github/workflows/release.yml@refs/tags/v0.3.0 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com "$f"
tar xzf "$f" && cd "${f%.tar.gz}" && sudo install -m 755 wasmkeep /usr/local/bin/wasmkeep
SHA256SUMS lists every file of the release, for tools that check
checksums; it is signed like the rest, so verify its own bundle the same
way before trusting it.
Or build on the server, or on a machine with the same OS and CPU architecture:
git clone https://github.com/wasmkeep/wasmkeep && cd wasmkeep
cargo build --release -p wasmkeep-cli
sudo install -m 755 target/release/wasmkeep /usr/local/bin/wasmkeep
Then a system user and its directories:
sudo useradd --system --home-dir /var/lib/wasmkeep --shell /usr/sbin/nologin wasmkeep
sudo install -d -o wasmkeep -g wasmkeep /var/lib/wasmkeep/{classes,data,cache}
Copy your components (built as in each SDK’s README) into
/var/lib/wasmkeep/classes/. The unit below serves two: the counter, which
a release carries prebuilt (counter.wasm, downloaded like the binary),
and a chat room built from examples/chat-room;
leave the chat lines out to serve the counter alone.
Configuration and secrets for a class (API keys, webhook URLs: see Configuration and secrets) go in a file only the service can read:
sudo install -d -m 750 -o root -g wasmkeep /etc/wasmkeep
sudo install -m 640 -o root -g wasmkeep /dev/null /etc/wasmkeep/chat.env
sudoedit /etc/wasmkeep/chat.env # NAME=value lines
2. Run it with systemd
/etc/systemd/system/wasmkeep.service:
[Unit]
Description=WasmKeep
After=network-online.target
Wants=network-online.target
[Service]
User=wasmkeep
Group=wasmkeep
ExecStart=/usr/local/bin/wasmkeep serve \
--actor chat=/var/lib/wasmkeep/classes/chat_room.wasm \
--actor counter=/var/lib/wasmkeep/classes/counter.wasm \
--data-dir /var/lib/wasmkeep/data \
--cache-dir /var/lib/wasmkeep/cache \
--env chat=/etc/wasmkeep/chat.env \
--listen 127.0.0.1:8080
Environment=RUST_LOG=info
Restart=on-failure
# SIGTERM: in-flight requests get up to 10 s, then actors finish their
# queued events (up to 10 s more) and, in a cluster, hand over. Give it room.
TimeoutStopSec=30
LimitNOFILE=65536
# Hardening. Do not add MemoryDenyWriteExecute (Wasmtime maps compiled code
# executable) or a LimitAS (the instance pool reserves a lot of virtual
# address space up front; it is not resident memory).
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/wasmkeep
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now wasmkeep
journalctl -u wasmkeep -f # logs; RUST_LOG=debug for more
curl localhost:8080/_wasmkeep/health # ok
curl localhost:8080/_wasmkeep/ready # ready (see docs/monitoring.md)
curl -X POST localhost:8080/actor/counter/hello
The first start compiles each component and caches the result in
--cache-dir; later starts take a fraction of a second.
3. Put a reverse proxy in front
WasmKeep speaks plain HTTP/1.1 and listens on localhost by default. Let a reverse proxy handle TLS and public traffic. With Caddy (automatic HTTPS, WebSockets included):
actors.example.com {
reverse_proxy 127.0.0.1:8080
}
With nginx, WebSockets need the upgrade headers:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 1h; # idle WebSockets
}
Every actor is reachable by anyone who knows its address, and a request to
a new name creates that actor. Put authentication in your actors, or in the
proxy. In an actor: give the class a token with --env, and compare the
Authorization header with it in fetch without stopping at the first
differing byte (Watch’s hub does this, examples/watch/hub). Calls between
actors carry x-wasmkeep-caller: <tenant>/<class>/<name>, which the host sets
and strips from outside requests, so an actor can accept some changes only
from another class.
Classes whose actors only other actors should create (a per-user actor
created at sign-up, a worker the scheduler starts) take --access CLASS=existing: a request for an unknown name answers 404 and creates
nothing, so no one can fill the disk one name at a time. --access CLASS=internal keeps a class away from outside requests entirely.
Actors’ outbound HTTP reaches public addresses only. If they must call a
service on this machine or your private network, allow it explicitly, as
narrowly as you can: --allow-outbound 10.0.5.20 (repeatable; ranges like
10.0.5.0/24 work too). Never allow 169.254.169.254: on a cloud VM it
hands out the machine’s credentials. If that service’s HTTPS certificate
comes from a private CA, add --outbound-ca /etc/wasmkeep/internal-ca.pem.
Ports other protocols use (mail on 25, 465 and 587, SSH, FTP and the rest
of the list browsers block) are refused too; --allow-outbound-port 587
opens one, for a mail relay your actors call over HTTP.
4. Back up the data
Each actor is one SQLite file:
<data-dir>/actors/<tenant>/<class>/<name>.db. Two ways to keep copies:
Continuous, to a bucket (recommended). Run the node as a one-node
cluster: every event’s writes are uploaded to an S3-compatible bucket
(AWS S3, Cloudflare R2, MinIO, anything with conditional writes), and a
lost disk is restored from it on the next request to each actor. Add to
ExecStart:
--node-id vps-1 --advertise http://127.0.0.1:8080 --bucket s3://my-bucket/wasmkeep \
--cluster-listen 127.0.0.1:7443 --cluster-advertise https://127.0.0.1:7443
With --node-id, a start also checks its node id in the bucket before it
listens, for up to 15 s: all of it the first time or after a crash, and
after a clean stop only what is left of 15 s since the node stopped
(why).
The last two flags give the node a cluster listener on loopback: nodes
talk to each other over mutual TLS, and a node in a cluster refuses to
start without one (or without --cluster-plaintext accept). Alone, it
carries nothing; when a second server joins, move it to an address the
other nodes reach.
Then give the unit the bucket credentials (better: from an
EnvironmentFile= readable only by root):
Environment=AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_REGION=auto
Environment=AWS_ENDPOINT=https://<account>.r2.cloudflarestorage.com
The bucket is then the only copy that survives the machine, and it needs a backup of its own: Back up and restore a cluster’s bucket.
Use exec-durable for writes that must survive the machine. Growing to
more servers later means starting them on the same bucket, with
--advertise set to an address the others can reach
(three nodes).
Snapshots. Without a bucket, copy the files with SQLite’s online backup, which is safe while the server runs:
cd /var/lib/wasmkeep/data
find actors -name '*.db' | while read -r db; do
mkdir -p "/backup/$(dirname "$db")"
sqlite3 "$db" ".backup '/backup/$db'"
done
A plain cp of a live database can produce a corrupt copy.
5. Updating
- New component version: replace the
.wasmandsystemctl restart wasmkeep. Each actor runson-initagain on its next event, so migrations happen lazily, per actor. - No-restart deploys need a bucket:
wasmkeep deploy --app acme/shop ...uploads a version, and nodes started with--app acme/shopswitch to it within seconds, actor by actor (see Running a cluster). - New WasmKeep binary: install it and restart. Open WebSockets are closed; clients reconnect.
Sizing
Memory is spent on resident actors only: about 250 KB each for a Rust
actor and 740 KB for a TypeScript one on QuickJS, the default engine (see
benchmarks);
hibernated actors (idle for 30 s) cost nothing but disk. These figures
assume transparent huge pages off, which wasmkeep serve arranges for
itself (monitoring); with
--transparent-huge-pages system on a kernel set to always, expect
about twice the memory. At most
--max-instances actors (1,000 by default) are resident at once; raise it
if more are active within the idle timeout, and budget the memory. CPU
scales with request rate.
Three nodes
A WasmKeep cluster: three servers sharing one S3-compatible bucket, behind a load balancer. Any node can lose its machine; its actors continue on the others 15 to about 21 s after its last heartbeat, then a restore (how), with no acknowledged durable write lost (measured: twelve hours and 35 kills on one lease per node, writable again 18.2 s after the kill at the median).
Read Running a cluster for how ownership, forwarding and replication work. This page is the setup. Each node is installed as in One server (binary, user, systemd unit, directories); only the flags differ.
Pre-alpha. The cluster is tested in simulation, with real processes against an S3 API, and against Cloudflare R2 and OVHcloud Object Storage with nodes killed under load (measured), but not yet under production load.
To try a cluster on one machine first, deploy/compose/
runs the three nodes below in containers against MinIO, with the same
flags.
┌──────────── load balancer (TLS) ────────────┐
▼ ▼ ▼
node a 10.0.0.11 node b 10.0.0.12 node c 10.0.0.13
└─ private network: mutual TLS on port 7443 ──┘
│
S3-compatible bucket (shared)
1. The bucket
Any S3-compatible store that supports conditional writes (If-None-Match
and If-Match on PUT): AWS S3, Cloudflare R2, OVHcloud Object Storage,
MinIO and others. Each node checks this at start and refuses to run on a
store that ignores them (Running a cluster). Create one
bucket (or a prefix in one) for the cluster, and credentials that can read,
write, list and delete in it.
Connection settings by provider
Every setting is an AWS_* variable in the nodes’ environment (the
EnvironmentFile= below); the bucket name and an optional prefix go in
--bucket s3://BUCKET/PREFIX. Keep the bucket in the same region as the
nodes: a durable write waits for one PUT to it, and a failover for many
reads (54 ms per durable write next to the bucket, 587 ms from a distant
desktop to R2: measured).
| Provider | Settings | Credentials |
|---|---|---|
| Cloudflare R2 (tested) | AWS_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com, AWS_REGION=auto | An R2 API token with Object Read & Write on this bucket only; its S3 access key id and secret |
| OVHcloud Object Storage (tested) | AWS_ENDPOINT=https://s3.<region>.io.cloud.ovh.net (for example s3.bhs.io.cloud.ovh.net), AWS_REGION=<region> (bhs) | An S3 user of the Public Cloud project with read and write on this bucket |
| AWS S3 | AWS_REGION=<the bucket's region>; no endpoint | An IAM user or role allowed s3:GetObject, s3:PutObject, s3:DeleteObject and s3:ListBucket on this bucket |
MinIO (in deploy/compose/) | AWS_ENDPOINT=http://<host>:9000, AWS_REGION=us-east-1, AWS_ALLOW_HTTP=true for a plain-HTTP endpoint on a private network | A MinIO user with read and write on this bucket |
“Tested” means the storage, failover and cluster suites and a 30-minute soak passed against it. Every node also checks conditional writes at start, whatever the provider. Requests cost money on R2 and S3 (before ADR 0020, per-actor lease renewals were most of them: see the benchmarks; node leases removed them), none on OVHcloud.
The bucket is the cluster’s source of truth and its root of trust: whoever can write to it can run code on the cluster (deployed components live there) and impersonate nodes (the inter-node secret lives there). Give its credentials to the nodes and to whoever deploys, no one else.
2. The nodes
Every node needs:
- a unique node id (
[a-z0-9-]):a,b,c. A node checks its id at start: it watchesnodes/<id>in the bucket for up to about 15 s (less after a clean stop or a fence) before taking any actor, and exits with an error if another process still writes it. Two processes started at the same moment can both pass that check; one then finds the other at its next write and exits, but until then both may run the same actor. When replacing a machine, stop the old process before starting the new one under the same id (see Running a cluster). - an advertise URL the other nodes can reach it at, on the private network.
- a cluster listener (
--cluster-listenand--cluster-advertise): a second port, 7443 here, where the nodes reach each other over mutual TLS. Nothing to provision: each node makes its own key at start and the others learn its fingerprint from the bucket (see Running a cluster). Without it a node refuses to start, unless given--cluster-plaintext accept, which makes nodes talk to each other in plain HTTP (a private network only). - the same
--bucket, and either the same--actorflags on every node or, better,--appto follow deploys.
On node a (/etc/wasmkeep/env, mode 600, then EnvironmentFile= in the
unit):
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=us-east-1
# AWS_ENDPOINT=... # R2, OVHcloud, MinIO: see "Connection settings by provider"
RUST_LOG=info
ExecStart=/usr/local/bin/wasmkeep serve \
--app acme/shop \
--bucket s3://acme-wasmkeep/prod \
--node-id a \
--advertise http://10.0.0.11:8080 \
--listen 10.0.0.11:8080 \
--cluster-listen 10.0.0.11:7443 \
--cluster-advertise https://10.0.0.11:7443 \
--data-dir /var/lib/wasmkeep/data \
--cache-dir /var/lib/wasmkeep/cache
Nodes b and c are the same with their own id and address. Listening on
the private address (not 0.0.0.0) keeps the nodes off the public
internet: only the load balancer should reach port 8080, and only the other
nodes port 7443 (firewall it to 10.0.0.11 to 10.0.0.13; never put it behind
the load balancer). Node traffic is encrypted, and 8080 carries no node
calls at all: plain HTTP between nodes is refused by default
(--cluster-plaintext refuse), so a signed call arriving there is
treated as a client’s. A cluster that ran in accept mode (without
listeners) moves to this in two rounds (see Operating it below); while
any node runs with --cluster-plaintext accept, keep 8080 private too.
Clocks should be synchronised (NTP, as on any cloud VM). The lease timings tolerate a few seconds of skew between nodes.
3. Deploy the application
--app nodes serve whatever was last deployed for the app, so deploy
before starting them (from any machine with the bucket credentials). This
page uses the counter from examples/counter as the app’s one actor
class:
cd examples/counter && cargo build --release --target wasm32-wasip2
wasmkeep deploy --app acme/shop --bucket s3://acme-wasmkeep/prod \
--actor counter=target/wasm32-wasip2/release/counter.wasm
deploy compiles and checks every component before uploading anything.
Later deploys work the same way: nodes pick the new version up within a few
seconds and switch each actor at its next event; --rollback returns to the
previous version. See Deploying.
4. The load balancer
Send traffic to all three nodes, health-checking GET /_wasmkeep/ready
(200 ready; 503 while the node shuts down, cannot reach the bucket or
runs out of disk, see Monitoring). A request can go to any node: if another node owns the actor, it is
forwarded there (one hop), and responses carry x-wasmkeep-node so you can
see where an actor runs. WebSockets work the same way.
No stickiness is needed. Routing by actor path (hashing
/actor/<class>/<name>) saves the forwarding hop, and is an optimisation,
not a requirement: see the front door for HAProxy and nginx
configurations, health checks, WebSocket timeouts, client addresses and WAF
placement.
5. Check it
curl -i -X POST https://actors.example.com/actor/counter/alice
# x-wasmkeep-node: b (whichever node took the actor)
# 1
Then stop that node (systemctl stop wasmkeep on b) and repeat the
request: another node answers at once with 2, the value preserved (a clean
stop hands the node’s actors over). Kill it instead (kill -9) to see a
crash: the actor comes back 15 to about 21 s after b’s last heartbeat
(10 s lease plus 5 s grace, then a peer fences it), plus a restore from
the bucket. Restart b: it rejoins and takes on new actors.
From any machine with the bucket credentials, wasmkeep cluster status --bucket s3://acme-wasmkeep/prod lists the nodes from their heartbeats
(live or not, version, addresses, TLS) and the deployed version of the app;
it exits with 1 if no node is live. See
Checking a cluster.
Operating it
-
Turning on the cluster listener in a cluster that runs with
--cluster-plaintext acceptand no listeners: add the two flags (keepacceptfor now) and restart one node at a time; nodes with and without it keep talking to each other meanwhile. A restarted node reports not ready for a few seconds while its new fingerprint reaches the others. -
Refusing plain HTTP between nodes, once every node has a cluster listener (
wasmkeep cluster statusshows a cluster address and a pin for every live node, and no warning about plain nodes): remove--cluster-plaintext acceptand restart one node at a time; each logs that it refuses plain HTTP. Never do it while any node lacks a listener: a refusing node does not reach that node’s actors (503), and that node’s calls to it are treated as a client’s. To roll back, addacceptto every node before removing any listener. -
Upgrading WasmKeep (the binary, not your app): upgrade every node together, not one at a time.
systemctl stop wasmkeepon all three, wait untilwasmkeep cluster status --bucket s3://acme-wasmkeep/prodexits with 1 (no live node, about 10 s after the last stop), install the new binary on all three, start them, and checkcluster statusagain: three live nodes, one version, no warning. Clients get errors between the last stop and the first start. Old and new nodes refuse each other’s calls and handle alarms differently, so a rolling restart across versions is not supported yet: see Upgrading WasmKeep. -
Restarting a node on the same binary (a configuration change, a host reboot): one node at a time is fine. A stopping node finishes its in-flight requests, then hands its actors over: the other nodes serve them on their next request. WebSockets to that node close and clients reconnect.
-
Updating the stateless component (the file given to
servefor paths outside/actor/and/_wasmkeep/): it is not part of a deploy. Copy the new file to every node and restart them one at a time (see Updating the stateless component). -
Adding a node: start it with a new id and add it to the load balancer. An actor in memory on another node stays there, and the new node forwards its requests; once it hibernates, its node gives it up, and its next request starts it on whichever node that request reaches, the new one included (see How it scales).
-
Secrets: keep them in the bucket rather than in each node’s
--envfiles. Create a key once (wasmkeep secrets new-key --out secrets.key), give it to every node (--secrets-key /etc/wasmkeep/secrets.key, mode 0600), and set values withwasmkeep secrets set; nodes pick them up within a few seconds (see Secrets in the bucket). -
Removing or replacing a node: drain it first, then stop it once it is drained:
wasmkeep cluster drain b --bucket s3://acme-wasmkeep/prod wasmkeep cluster status --bucket s3://acme-wasmkeep/prod # STATE drained sudo systemctl stop wasmkeep # on node bA drain moves its actors a few at a time while the node keeps forwarding, and moves their pending alarms with them, so they fire on time (see Draining a node). Stopping without draining also works (
SIGTERM, assystemctl stopsends): its actors move at once, and alarms of idle actors wait for the sweep. A node that is killed or crashes cannot hand over: its actors move after a peer fences its heartbeat (15 to about 21 s after its last write) and a restore, and their alarms arrive later still (measured). -
Stop timeouts: a clean stop can take up to about 29 s (10 s for in-flight requests, 10 s for actors to finish their queued events, 3 s for the handover to reach the bucket, about 6 s for the node to release its own lease). Allow 30 s:
TimeoutStopSec=30in systemd,--stop-timeout 30for Docker (its default is 10 s),terminationGracePeriodSeconds: 30in Kubernetes. A node killed earlier is treated as a crash, which is safe, only slower. -
Losing a node: nothing to do. Its actors move on their next request; writes acknowledged by
exec-durableare in the bucket, a plainexec’s last event may be lost (see Running a cluster). -
Logs: each node logs to the journal;
RUST_LOG=wasmkeep_server=debugshows leases, forwarding and replication decisions. -
Backups: the bucket is the only durable copy. Copy it somewhere the nodes cannot write, and restore that copy once before you need it (see Back up and restore a cluster’s bucket).
-
Bucket growth: each actor’s history older than an hour (
--history-retention-secs) is collected after its periodic snapshot, a deleted actor’s at once, andwasmkeep deploykeeps the current version and the five before it (see History in the bucket).
One region
All nodes should be in one region, close to the bucket: every durable write and every ownership change is a round trip to it. Multi-region placement is on the roadmap, not in this release.
The front door: load balancer, WAF and routing
How traffic reaches a cluster on dedicated servers or virtual machines.
clients ── HTTPS, WebSockets
│
▼
WAF / CDN (optional) Cloudflare, or Coraza/ModSecurity at the proxy
│
▼
load balancer HAProxy, nginx, Caddy or a cloud LB: terminates TLS
│ plain HTTP to any node's --listen port, on the private network
▼
node a node b node c any node takes any request;
└── forwards to the actor's owner, at most one hop ──┘
│
bucket leases, heartbeats, replication
- TLS ends at the load balancer. Nodes serve plain HTTP on
--listen; bind it to the private network. - Any node will do. If another node owns the actor, the request is
forwarded there (once, never twice), and the response says which node
served it (
x-wasmkeep-node). - Health-check
GET /_wasmkeep/ready:503while a node shuts down, cannot reach the bucket or is nearly out of disk (monitoring)./_wasmkeep/healthonly says the process answers. - WebSockets need the upgrade passed through and a tunnel or idle timeout above 30 s; the usual default of 60 s works (WasmKeep pings a socket whose client has been silent for 25 s, and closes it 30 s later if there is no answer).
- A server timeout above the failover wait. When a node dies, the
others keep a
GET, aHEADor a WebSocket upgrade for one of its actors until they took the actor over, up to 25 s by default (--failover-wait-secs, see what a caller sees when a node dies), and a kept request sent on to the node that took the actor can wait 30 s more there. A load balancer that gives up on the node sooner turns that wait into its own 504: give it 60 s or more for the node’s answer, or lower the wait on the nodes to fit. - Tell the node when the client is gone. A kept request is dropped, and never run, when its connection closes. If the load balancer keeps its connection to the node open after its own client left, the node cannot know, and runs the request for nobody once the actor is taken over.
- Never expose the metrics listener (
--metrics-listen) or the node-to-node traffic to the internet.
Route by actor (recommended)
Hash the actor’s path, /actor/<class>/<name>, so that every request for
an actor reaches the same node. The first node to get a request for an
actor nobody owns takes its lease, so actors settle where their traffic
arrives and forwarding all but disappears. It is only a hint: when nodes
come and go, ownership still follows the leases, and a request that lands
elsewhere is forwarded.
HAProxy: balance uri depth 3 hashes the first three path segments
(/actor, the class, the name):
frontend public
bind :443 ssl crt /etc/haproxy/site.pem alpn http/1.1
# Client IP for actors: replace whatever the client sent.
http-request del-header X-Forwarded-For
option forwardfor
default_backend wasmkeep
backend wasmkeep
balance uri depth 3
hash-type consistent
option httpchk GET /_wasmkeep/ready
http-check expect status 200
# Above the failover wait (25 s) plus a forward to the new owner.
timeout server 60s
timeout tunnel 1h
# A node that refuses the connection: try another one.
retries 2
option redispatch
# The client left while its request waited: close the node's side too.
option abortonclose
default-server inter 2s fall 2 rise 2
server a 10.0.0.11:8080 check
server b 10.0.0.12:8080 check
server c 10.0.0.13:8080 check
timeout server is how long HAProxy waits for a node’s answer: it must
be longer than the nodes’ failover wait, or HAProxy answers 504 while the
node still holds the request. option redispatch with retries sends a
request whose connection a node refused (it just died, and its health
check has not failed yet) to another node, where it can be kept, instead
of failing it; it applies to connections that could not be opened, so no
request is sent twice. option abortonclose asks HAProxy to give up a
request whose client closed its connection before an answer, and so to
close its connection to the node: that is what lets a node drop a kept
request nobody waits for. This example was not run against HAProxy with a
kept request: check with your version that a client’s departure closes
the node’s side while HAProxy waits for the answer (the node then counts
outcome="left" in wasmkeep_failover_waits_total). Another proxy needs
its own equivalent; without one, a kept GET runs after the takeover for
a client that has left.
nginx: hash a variable holding the actor’s path:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
map $uri $wasmkeep_actor {
~^(/actor/[^/]+/[^/]+) $1;
default $uri;
}
upstream wasmkeep {
hash $wasmkeep_actor consistent;
server 10.0.0.11:8080;
server 10.0.0.12:8080;
server 10.0.0.13:8080;
}
server {
listen 443 ssl;
location / {
proxy_pass http://wasmkeep;
proxy_http_version 1.1;
proxy_set_header Host $host; # the client's, not the upstream's name
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Forwarded-For $remote_addr; # replace, never append
proxy_read_timeout 1h;
}
}
Open-source nginx has no active health checks: pair it with an external
check of /_wasmkeep/ready, or use HAProxy. Caddy is the simplest for
one node (see one server);
for a cluster its lb_policy uri_hash hashes the whole URI, so requests to
different paths of one actor may land on different nodes (correct, with a
forwarding hop).
Client addresses
WasmKeep passes request headers through to actors unchanged, except its own
x-wasmkeep-* (stripped or set) and, when a node forwards a request to the
actor’s owner, hop-by-hop headers (Connection, Keep-Alive, TE,
Upgrade and the like, and any header Connection names; RFC 9110
section 7.6.1). An actor that reads
X-Forwarded-For trusts whatever the load balancer put there: the load
balancer must replace the header, not append to it, and nodes must be
reachable only through it.
Static files
Serve static files (HTML, JavaScript, CSS, images, fonts, video,
documents) from the front door, not from actors: the reverse proxy itself,
or object storage behind a CDN. Route actor paths (/actor/...) to
WasmKeep, and everything else to the static origin.
Actors are for state with an identity: one instance per name, one event
at a time, a lease and a database. A file has neither. Served by an actor,
every request would go through the instance pool and the Wasm sandbox,
take the actor’s lease (so one node serves it, not all of them), and the
guest would have to implement caching headers (ETag, 304 answers) and
range requests (needed for video) itself. A web server or CDN does all of
that from disk or cache at almost no CPU cost.
Caddy, files from a directory and actors on the nodes:
example.com {
@actors path /actor/* /_wasmkeep/*
handle @actors {
reverse_proxy 10.0.0.11:8080 10.0.0.12:8080 10.0.0.13:8080 {
health_uri /_wasmkeep/ready
}
}
handle {
root * ./public
file_server
}
}
With a CDN, the same split works: the static paths go to a bucket or static host, the rest to your load balancer.
A few small files embedded in a component (an admin page, a favicon) are fine; anything larger, changing often, or large media belongs on the static origin. Serving assets directly from the nodes, without an actor, is a possible built-in for later (roadmap).
WAF and rate limits
Filter and rate-limit at the edge: a CDN such as Cloudflare, or a WAF at
the proxy (OWASP Core Rule Set with Coraza or ModSecurity), and per-client
rate limits in the load balancer (HAProxy stick tables, nginx
limit_req). WasmKeep’s own limits are a backstop, not a WAF: a connection
cap, 30 s to send headers, 16 MB request bodies, WebSocket message and
socket caps (security model). Keep the edge’s body limit
at or below 16 MB.
Without a separate load balancer
Every WasmKeep node already routes: whichever node a request reaches, it is served by the actor’s owner. What a load balancer adds is one address that survives a node dying, TLS, and filtering. The three servers can provide those themselves.
| Setup | Pros | Cons |
|---|---|---|
| DNS round robin, Caddy on every node | No extra machine; automatic certificates; the simplest | A dead node keeps getting a share of new connections until DNS caches expire (browsers usually retry another address, many API clients do not); no health checks; no routing by actor, so more forwarding |
| A floating IP moved by keepalived, HAProxy on the nodes | No extra machine; failover in seconds; health checks and routing by actor | One node takes all incoming traffic at a time; keepalived to set up |
| A managed load balancer (Hetzner, DigitalOcean, AWS…) | Nothing to run; health checks | A small monthly cost; some cannot hash on a path prefix |
| An outbound tunnel, HAProxy on the nodes | No inbound port and no public address on the nodes; TLS ends at the provider | Every request goes through the provider; a connector to run on each node; needs the loopback load balancer below |
DNS round robin with Caddy
Publish one name with every node’s public address (three A or AAAA
records, short TTL), and run Caddy beside WasmKeep on each node. WasmKeep
listens on the node’s private address (--listen 10.0.0.11:8080, the
address the other nodes forward to); Caddy takes the public one:
app.example.com {
reverse_proxy 10.0.0.11:8080 {
header_up X-Forwarded-For {remote_host} # replace, never append
}
}
Each node gets its own certificate. Requests for an actor owned elsewhere are forwarded by WasmKeep over the private network.
A floating IP with keepalived
Most providers sell an address you can move between servers (Hetzner,
OVH, Scaleway, DigitalOcean reserved IPs, or a VRRP address on your own
network). keepalived on each node holds it on one healthy node and moves
it within seconds when that node or its WasmKeep stops answering. HAProxy
on every node (configured as above, with routing by actor and
/_wasmkeep/ready checks against all three nodes) terminates TLS on the
floating address:
vrrp_script wasmkeep_ready {
script "/usr/bin/curl -fsS --max-time 2 http://10.0.0.11:8080/_wasmkeep/ready"
interval 2
fall 2
rise 2
}
vrrp_instance front {
state BACKUP # all nodes start as backup; priority decides
interface eth1 # the private network
virtual_router_id 51
priority 100 # 100, 90, 80 on the three nodes
advert_int 1
unicast_peer { 10.0.0.12 10.0.0.13 } # the other nodes
virtual_ipaddress { 203.0.113.10/32 dev eth0 }
track_script { wasmkeep_ready }
}
On providers whose floating IP is moved through an API (Hetzner Cloud,
DigitalOcean), call it from keepalived’s notify_master script instead of
announcing the address with virtual_ipaddress. Bind HAProxy to the
floating address with net.ipv4.ip_nonlocal_bind = 1, so every node can
start it before holding the address.
An outbound tunnel
Some providers reach your servers through a connector: a process on each machine that dials out to the provider and receives the traffic for your name over that connection. No inbound port is opened, the nodes need no public address, and TLS ends at the provider. Run one connector on each node, all for the same name; the provider spreads requests over the connectors that are connected.
The obvious configuration is wrong for a cluster. A connector that sends
its traffic to the node on its own machine (http://10.0.0.11:8080) has
that node as its only origin. The connector is its own process: it stays
connected while WasmKeep on that machine is stopped, restarting or not
ready, so it keeps receiving its share of the requests, and answers each
with a gateway error for as long as its node is down. The other nodes are
healthy the whole time and get none of that share.
Put this guide’s load balancer between each connector and the nodes,
bound to loopback. HAProxy on every node, with the wasmkeep backend
above (all three nodes, checked on
/_wasmkeep/ready, hashed by actor), and this frontend in place of the
public one:
frontend tunnel
# Only the connector on this machine connects here.
bind 127.0.0.1:8081
# Whatever the client sent; see "Client addresses" below.
http-request del-header X-Forwarded-For
default_backend wasmkeep
Point each connector at http://127.0.0.1:8081. A request now reaches a
node that is ready whichever connector received it, and the same node for
the same actor from all three, as long as every HAProxy lists the same
servers in the same order (the consistent hash depends on it; not
exercised here). A machine that is down takes its connector down with
it, and the provider stops using that connector.
- TLS is the provider’s: no certificate on the nodes, so no
sslon thebindline. - The nodes still listen on the private network (
--listen 10.0.0.11:8080): the HAProxy of the other machines sends to them there. Nodes forward to each other on the cluster listener (port 7443 in the three-node guide), which must stay reachable between the machines. - Client addresses. HAProxy sees the connector, at 127.0.0.1, so
option forwardforwould write that. The client’s address is in whatever header the provider sets: read that one in your actors. The frontend deletesX-Forwarded-For, which may be the client’s own invention; keep it only if your provider replaces what the client sent. An actor may trust the provider’s header only while nothing but the connectors can reach HAProxy and the nodes (Client addresses). - WebSockets need the provider to pass the upgrade and to keep an idle connection open for more than 30 s, as for any proxy in front.
Plain DNS round robin, no proxy at all
Not recommended: nodes speak plain HTTP, so there is no TLS without a proxy in front, and WasmKeep has no rate limiting or filtering of its own beyond its protective limits.
Back up and restore a cluster’s bucket
The bucket is the cluster’s only durable copy of everything: a node’s disk is a cache of it (replication). This page says what is in the bucket, how to copy it so that the copy can be restored, how to restore it, and how to check that it works.
Pre-alpha, and a guide without a tool. WasmKeep has no command to back up a bucket, to verify one or a copy of one, to restore one, or to export or restore a single actor. Verifying a bucket and exporting an actor are on the roadmap. Until then a backup is a copy made with an S3 tool, and the only proof that it restores is a restore drill (below).
What is in the bucket
Everything under the prefix given to --bucket s3://BUCKET/PREFIX:
| Keys | What | Written |
|---|---|---|
actors/<tenant>/<class>/<name>/wal/ | One object for every write an actor committed (a segment) | Created once, never changed |
actors/<tenant>/<class>/<name>/snapshot/ | The whole database, every 64 writes | Created once, never changed |
owners/<tenant>/<class>/<name> | Which node holds the actor, with a generation that grows by one at every hold | Replaced when the actor changes hands |
nodes/<id> | Each node’s heartbeat, which is its lease | Replaced every 3 s |
alarms/<tenant>/... | The index of pending alarms, by due time | Created and deleted as alarms change |
apps/<tenant>/<app>/<version>/ and apps/<tenant>/<app>/current | Deployed versions (components, access rules, configuration) and the pointer to the current one | Versions created once; current replaced by a deploy |
apps/<tenant>/<app>/secrets/ | The app’s secrets, sealed, and their own current | As above |
cluster/secret | The secret nodes sign their calls to each other with | Created once |
control/ | Drain and move requests | By the operator’s commands |
leases/<tenant>/<class>/<name> | One small marker per actor, written by the node that first takes it. It carries the generation the actor’s first ownership record starts from, and keeps a binary from before node leases from taking the actor (ADR 0020) | Created once, never collected |
probe/ | The object a node’s start check writes to test conditional writes | Deleted right after the check |
History objects are deleted only by garbage collection, by the node that
holds the actor, after a newer snapshot of its own was acknowledged, and
only once they are older than --history-retention-secs (one hour by
default). A deleted actor’s history is erased at once
(History in the bucket,
ADR 0012).
Not in the bucket, to keep with the backup:
- the secrets key (
--secrets-key). Without it the sealed secrets in a copy cannot be opened; - each node’s
--envfiles, the stateless component if you serve one, and theservecommand lines (the systemd units); - with
--actorinstead of--app, the components themselves.
A copy holds every actor’s data and the cluster secret in the clear. Protect it like the bucket (security model).
What a restorable copy needs
To restore an actor, a node lists the actor’s prefix, takes the newest
segment of the highest generation as the head, and walks back from it,
segment by segment, to the first snapshot on the way
(ADR 0007). So a copy restores an
actor if it holds that snapshot and every segment after it up to the
head. Objects besides those do no harm. A segment missing in the middle
is an error, not a rollback: the actor answers 500 until the history is
repaired, and the other actors are not affected.
With the cluster stopped: restorable
Stop every node cleanly and wait until wasmkeep cluster status exits
with 1 (no live node). A clean stop gives each actor up to 10 s to finish
its queued events and their uploads before the node gives it up. A node
that logs shutdown: some actors did not finish in time cut some events
short: a plain exec among them may not be in the bucket, and is then
not in the copy either. Once every process has exited nothing writes or
deletes any more, as long as nobody runs wasmkeep deploy or
wasmkeep secrets meanwhile, and the copy is the bucket at one moment.
This is the one way the code shows to be restorable. Its cost is the
cluster being down while the copy runs.
With the cluster running: not established
A copy made object by object reads a listing first and the objects afterwards, while nodes keep writing and collecting. From the code:
- Segments and snapshots are never changed after they are created, so an object that was copied is a good object.
- Garbage collection can delete an object between the listing and its copy. Take an actor whose last snapshot is older than the retention window. It reaches 64 writes during the copy, uploads a new snapshot and deletes the old one with the segments after it that are older than the window. The copy then has the newer segments the listing named, without the snapshot they start from and without the new one, which the listing did not name. That actor does not restore from that copy.
- A second copy into the same destination, started right after the first and without deleting anything there, brings the new snapshot and what follows it. Everything created since the first listing is younger than the retention window, so collection cannot delete it while the second pass runs, if both passes end within the window (an actor deleted meanwhile is erased at once, whatever the window). Extra objects in the destination do no harm to a restore. By that reasoning two passes into an empty destination close the gap for history. That is reasoning, not a test: nothing in the repository restores a cluster from such a copy.
- The other objects are copied at other moments than the history. An ownership record copied before a takeover, next to segments copied after it, carries a lower generation than those segments, and the next hold would use a generation that is already in the history. An alarm index copied before or after its actor’s database can list an alarm the database does not have, or miss one it has. What a cluster started on such a copy does in each of these cases is not established.
So: a copy of a running cluster is better than none, and most actors in it restore. It is not a backup the code shows to be restorable. If the cluster cannot stop, bucket versioning is the other way, with unknowns of its own.
Bucket versioning
With versioning turned on in the bucket (AWS S3, and the S3-compatible stores that implement it), a delete or a replace keeps the earlier version of the object. Collection then removes nothing for good, and the state of every key at one past moment can be rebuilt from the versions, without stopping anything. The state at one moment is what a cluster whose nodes all died at that moment would leave: nodes that die give nothing up, and the next ones take their actors from the bucket, which is what failover does.
What it costs and what is not checked:
- Rebuilding “every key as it was at time T” into another prefix is a job for the provider’s tools or a script over the version listing. WasmKeep has no command for it, and nothing in the repository runs a cluster on a versioned bucket or restores one from versions: the paragraph above is what versioning promises, not something measured here.
- Nothing is deleted any more, so the bucket grows until a lifecycle rule expires old versions. That rule’s age is how far back you can restore.
- A deleted actor’s data stays in the old versions until that rule
removes it.
delete-selferases nothing for good before then.
An example copy
An example with rclone, one S3 tool among several. It is an example, not a tested procedure: nothing in the repository runs it, against any provider. Check how your tool lists, retries and verifies on your store, then prove the copy with a restore drill.
# The cluster is stopped: this must print no live node and exit with 1.
wasmkeep cluster status --bucket s3://acme-wasmkeep/prod
# Copy the prefix to another bucket, into a new folder per day.
rclone copy src:acme-wasmkeep/prod dst:acme-wasmkeep-backup/2026-11-12/prod
# Compare sizes and checksums of both sides.
rclone check src:acme-wasmkeep/prod dst:acme-wasmkeep-backup/2026-11-12/prod
Keep the copy in another account or at another provider than the bucket, with credentials the nodes do not have: whoever can write the bucket can also delete it. Start the nodes again when the check has passed.
Restoring
-
Stop every node and wait until
wasmkeep cluster statusexits with 1. Never restore under running nodes: they hold a newer deploy and newer secrets in memory than the copy names. They keep running the newer deploy, and they refuse the older secrets: 503 for the app after 30 s, until they are restarted (Secrets in the bucket). -
Copy the backup into an empty prefix, not over the damaged one. Objects written after the backup carry higher positions and generations than the copy’s, and a restore takes the highest as the head: left in place, they win over what you copied back. Keeping the damaged prefix also keeps a way back.
rclone copy dst:acme-wasmkeep-backup/2026-11-12/prod src:acme-wasmkeep-restored/prodKeep the prefix’s name (
prodhere) and change the bucket if the app has secrets in the bucket: a sealed secret opens only under the prefix, tenant and app it was sealed for. Under another prefix, write the secrets again withwasmkeep secrets reset. -
Move each node’s data directory aside (
--data-dir), or decide to keep it knowingly. A node replaces a local database that is not at the bucket’s head, keeping the old file as<name>.db.replaced(one generation only: a second replacement overwrites it). But for an actor the bucket knows nothing about, the local database becomes its first snapshot: with the old directories in place, actors created after the backup come back from disk, on whichever node has their file. -
Start the nodes with
--bucketnaming the restored prefix, and with the same node ids as before. -
Check:
wasmkeep cluster statuslists every node live, and the deployed version andseqof the backup. Request a few actors you know.
What a restored cluster is:
- The state of the backup. Every write since is gone, durable or not.
- The deploy and the secrets of the backup.
apps/.../currentnames the version that was current then. Deploy again for a newer one, and set again any secret changed since. - Free to take. In a copy made with the cluster stopped, the node
leases say
released, and a record that still names one of those nodes is free: the first request for an actor starts it. In a copy made while nodes ran, the leases sayliveand records name the processes of that time. A node started under the same id replaces its lease after the start check (about 15 s when the copy’s lease sayslive), which frees the records naming its old process. An id nobody starts again is fenced by the other nodes, or withwasmkeep cluster fence <node>. - Alarms that came due since the backup fire soon after the start, and anything the actors did outside between the backup and the restore (a webhook sent, an email, a call to your API) is unknown to the restored state and may happen again. Alarms are at least once; a restore makes that visible.
- Drain requests of the backup apply again: a node that was draining
when the copy was made drains again.
wasmkeep cluster undrain <node>withdraws it. A move request in the copy may apply again too, for up to 5 minutes after the restore copy: a node counts a move request’s age from the object’s date in the bucket, which a copy normally sets anew (not tested). It expires by itself.
One actor cannot be restored alone today: there is no command for it, and copying one actor’s old objects under a running cluster changes nothing, since its current history has the higher positions.
A restore drill
A backup that has never been restored is a hope. Do this before you need it, and again after every WasmKeep upgrade:
- Copy last night’s backup into an empty prefix of a scratch bucket, with the same prefix name.
- Start one node on it, on a machine that is not part of the cluster,
with an empty data directory, the same app and the secrets key:
wasmkeep serve --app acme/shop --bucket s3://scratch/prod --node-id drill .... Restored actors fire their overdue alarms and call out, and outbound HTTP reaches public addresses by default: if the actors send webhooks or call an API, first block that machine’s outbound traffic except to the scratch bucket’s endpoint (a firewall rule that allows only the bucket), and give it no--allow-outbound. The node itself must still reach the bucket: without it, it never starts serving. - Request a sample of actors, including the largest databases and some
that had not been written for a long time. A
500is an actor that did not restore: look at the node’s log for the missing object. - Compare what they answer with what you expect at the time of the backup.
- Write down how long the copy and the first requests took. That is your restore time.
The drill reads every object only for the actors it requests. Until a verify command exists, the sample is the measure of the copy.
Monitoring
What a node tells you about itself: two health endpoints on the main port, and Prometheus metrics on a port of their own.
Health and readiness
| Path | Answers |
|---|---|
GET /_wasmkeep/health | 200 ok while the process accepts requests. |
GET /_wasmkeep/ready | 200 ready when the node should get traffic; 503 not ready otherwise. |
A node is not ready once it starts shutting down; in a cluster, until it
has checked that the bucket enforces conditional writes and that no other
process runs with its node id (for good if either fails: serve exits),
while it has not loaded the cluster secret or its node lease (the heartbeat,
ADR 0020) is not valid, which takes the
bucket refusing or not answering its writes for the lease’s ttl minus its
safety margin (8 s by default; calls and durable writes would fail); and when the
disk holding --data-dir has less than 1 GiB free (or 5% of a smaller
disk), sampled every 5 s off the request path. The public answer gives no
reason (the port faces clients); wasmkeep_not_ready{reason} on the metrics
page does.
A node that stops for good says why in the same words in that reason and
at the start of serve’s exit line, followed there by the details:
| Reason | What it found |
|---|---|
another process runs with this node id | another process writes this node id |
this node was fenced | a peer fenced its lease after it stopped renewing, or an operator ran wasmkeep cluster fence |
this node's lease was released by another process | another process stopped cleanly under this id |
this node's lease object was deleted | its nodes/<id> object was removed |
an older version of WasmKeep runs with this node id | a heartbeat in the format from before node leases under this id |
a node of an older WasmKeep version runs in this cluster | another node of a version from before node leases (stop every older node first) |
this node's lease object holds something this version cannot read | an unreadable object under this id |
the bucket does not enforce conditional writes | the bucket check at start failed |
Point load balancers at /_wasmkeep/ready, and use /_wasmkeep/health for
“restart it if it stops answering”.
Open files
Every resident actor holds three open files (its database, write-ahead log
and shared-memory index), every connection one. At start, wasmkeep serve
raises its soft open-files limit to the hard limit, then caps connections
and resident actors to what fits, logging a warning when that lowers them.
With a cluster listener (--cluster-listen), its connections (up to
1,024) are counted first.
Give the process a generous limit (LimitNOFILE=65536 in the systemd unit,
or ulimit -n) to serve many actors at once.
The resident cap includes actors still closing their databases and blocking
database work left by a cancelled event. An address without a mailbox
(including a hibernated or failed-over actor) waits up to 250 ms for an idle
actor’s resources to close. If capacity remains full, its request receives
503 Service Unavailable with Retry-After: 1. Registered mailboxes can
still receive events, subject to their own queue limit (429). Relaying
to another owner does not take a local actor slot when the owner is known
(learned in the last second, or read from the actor’s ownership record within the
admission wait); otherwise the request goes through local admission.
Clients should honor the retry delay and bound their retries.
Alarms refused by admission use exponential backoff with jitter: a delay between half and all of a ceiling that starts at 100 ms and reaches 5 s. The retry counter below makes sustained admission pressure observable. WebSocket messages retain their frame while waiting up to the event wall limit; close callbacks remain pending through capacity or queue pressure. Closing sockets keep counting against socket limits until cleanup ends.
This admission rule uses the startup budget; it is not a live count of all process descriptors. Unrelated files and outbound connections still need the budget’s reserved headroom. The resident metric below counts registered mailboxes, so it can briefly fall before retiring resources release capacity.
Memory
Each resident actor costs about 250 KB for Rust and 740 KB for TypeScript
on QuickJS (benchmarks); wasmkeep_actors_resident times
that, plus the compiled classes, is roughly the process’s resident memory.
On Linux, wasmkeep serve and wasmkeep dev turn transparent huge pages
off for their own process. Where the kernel sets them to always (some
desktop and tuned distributions do; most servers use madvise), each
actor’s memory would otherwise sit on 2 MiB pages it mostly does not
use: on one such machine, a Rust actor measured 577 KB instead of 250 KB,
with no gain in CPU or latency. To check a running node:
cat /sys/kernel/mm/transparent_hugepage/enabled # the kernel's setting
pid=$(pidof wasmkeep)
grep THP_enabled /proc/$pid/status # 0: off for WasmKeep
--transparent-huge-pages system keeps the kernel’s setting instead. If
the process cannot turn them off, it logs a warning and carries on.
Metrics
wasmkeep serve ... --metrics-listen 127.0.0.1:9100
curl 127.0.0.1:9100/metrics
Off by default. The page names classes and counts events, so bind it to loopback or a private address that only your Prometheus reaches. Labels are class names, event kinds and fixed reasons, never actor names.
| Metric | Type | What it tells you |
|---|---|---|
wasmkeep_actors_resident | gauge | Actors in memory now: capacity planning. |
wasmkeep_websockets_open | gauge | Registered WebSockets, including pending close cleanup. |
wasmkeep_classes | gauge | Classes served. |
wasmkeep_ready, wasmkeep_not_ready{reason} | gauges | 1 when /_wasmkeep/ready answers 200; otherwise one wasmkeep_not_ready series saying why. |
wasmkeep_events_total{class,kind,outcome} | counter | Events run; kind is request, alarm, websocket or init, outcome is ok or failed (trapped, cut, or a handler that returned an error). |
wasmkeep_event_duration_seconds{class} | histogram | Slow handlers and hot classes. |
wasmkeep_event_cuts_total{reason} | counter | Events the host stopped: wall (past the wall-clock limit) or lease (this node lost the actor mid-event). |
wasmkeep_cpu_budget_exceeded_total | counter | Guest calls stopped for using their CPU budget. |
wasmkeep_instantiate_seconds | histogram | Creating an actor’s instance: the wake cost. |
wasmkeep_wakes_total, wasmkeep_hibernations_total | counters | Actor instances created (first request or wake) and dropped (hibernated, evicted, discarded after a trap, replaced by a new version, shut down): is the idle timeout right? |
wasmkeep_forwards_total{kind} | counter | Requests (request), sockets (websocket) and calls (call) sent to the node owning the actor: placement efficiency. |
wasmkeep_failover_waits_total{kind,outcome} | counter | Requests (request) and WebSocket upgrades (websocket) for an actor whose node was dead or stopping that this node kept until the actor could be taken over, instead of answering 503 at once (what a caller sees when a node dies), by how the wait ended: served (this node took the actor and answered), forwarded (another node took it first; sent there once), owner_alive (the owner wrote its lease again or restarted; sent to it as usual), expired (the wait ran out: 503), shutdown and lease_lost (this node began to stop, or its own lease ran out: 503), left (the client closed its connection: never run), answered_other (asked for the actor here and answered otherwise: the node full, the takeover failed, an access rule; or the client left during that try). over_cap counts requests that would have been kept and got the 503 at once because the node already kept a quarter of its connection limit, or 64 for that actor. expired rising when a node dies: the wait is shorter than the takeover (--failover-wait-secs), or no node could fence the dead one. |
wasmkeep_durable_write_seconds | histogram | exec-durable until durable: the bucket’s health as actors feel it. |
wasmkeep_requests_abandoned_total | counter | Requests and actor calls not run because their sender had gone while they waited in the actor’s queue (behind a long event, or a takeover’s restore): a client that closed its connection, or a caller whose call timed out. Nobody would get the answer, and nothing they would have written was acknowledged. A request that has started runs to its end. Seen only for a request received whole (no body, or one that arrived in the server’s first read, about 8 KiB): one whose body is still streaming (larger, chunked, or waiting for 100 Continue) runs as before. |
wasmkeep_restore_read_seconds | histogram | One object (a segment or a snapshot) a restore read from the bucket, from its request to its last byte, resends included. A restore keeps up to 8 reads, and 32 MiB by their listed sizes, under way while it applies the objects in order (always at least one, however large); a node’s restores together read at most 32 objects and 256 MiB at once, one read counting for at most 32 MiB. The time starts once a read has its share, so waiting for it is not counted, and reads overlap, so their sum can exceed the restore’s time. A read is resent when the bucket leaves it unanswered for 5 s, when it takes longer than 5 s plus a second per megabyte read alongside it on the node (its own included), or when the bucket refuses it with an error a resend can get past (after a pause of half a second, then a second, without its share, then the wait for a share again: both are in this time); three tries at most. |
wasmkeep_restore_failures_total | counter | Restores of an actor from the bucket that did not complete, whatever the reason: a read that failed three times, the bucket refusing, the deadline of its reads (30 s, plus a second per megabyte and a second per eight objects of what it reads, counting the time it waits for the bucket to give it the next object: not the listing, local disk work, waiting for a turn among the node’s other restores, or the seconds those restores’ bytes in flight add to a read’s limit), a history that cannot be restored, or a local file error. A restore that fails before its database is complete leaves the node’s files as they were; one that fails putting the complete database in place (a local file error) has set the previous file aside, and the next restore reads the bucket again either way. For the bucket’s reasons the request that woke the actor got a 503 with Retry-After and the next one restores again from a fresh listing; the log line restore failed names the actor and the reason. A few during a bucket incident are expected; steadily rising with a healthy bucket means actors that cannot be restored in their time (see wasmkeep_restore_read_seconds) or a damaged history. |
wasmkeep_listing_seconds | histogram | One listing of the cluster’s heartbeats, with the view and the peers’ pins it refreshes (every heartbeat period, and early when an unknown pin knocks). Near the heartbeat horizon (10 s by default), peers’ pins expire before the next listing renews them and handshakes between nodes are refused; fences run on a task of their own and are not counted. |
wasmkeep_guest_log_lines_total, wasmkeep_guest_log_dropped_total | counters | Lines actors printed that reached the log, and that were dropped over the rate (what actors print). |
wasmkeep_admission_refused_total{reason} | counter | Requests, WebSocket upgrades and actor calls this node refused before their event ran, by reason: access (the class’s access rule: an internal class reached from outside, 403, or a name an existing class does not let an outside request create, 404), capacity (no resident capacity, 503 with Retry-After, or a WebSocket over the socket limits), overloaded (the actor’s queue was full: 429 for requests and calls, 503 for a WebSocket upgrade) and unavailable (the node cannot run the actor now, 503: a required value not set, the deployed version or the app’s secrets not applied, the bucket not answering for the actor’s ownership; on a WebSocket upgrade, an ownership record that cannot be read also answers 503 and is counted here, where a request gets a 500 and is not counted). A 503 while an actor’s ownership changes hands between nodes is not a refusal and is not counted. All four series are always on the page. Each refusal is counted once, by the node that answered it: one forwarded to the actor’s node is counted there. Alarms are not counted here (wasmkeep_alarm_admission_retries_total). capacity rising: more actors awake at once than the node holds (see open files and memory); access rising: clients or links aimed at actors they may not reach. |
wasmkeep_alarm_admission_retries_total | counter | Alarm deliveries rescheduled because resident capacity or an actor queue was full; no actor labels. |
wasmkeep_node_lease_writes_total, wasmkeep_node_lease_failures_total | counters | Writes of this node’s lease (its heartbeat, ADR 0020) that reached the bucket, and that failed or timed out. With the defaults (a write every 3 s, valid for 8 s), two failing in a row and the lease runs out: the node reports not ready. A write that hangs is counted when it times out (2.7 s), so alert on the first increment. |
wasmkeep_node_fences_total | counter | Fences this node wrote over a node that stopped renewing its lease (crashed, or cut off from the bucket). |
wasmkeep_bucket_requests_total{operation,status} | counter | Requests this node made to the bucket: cost and errors. See below. |
wasmkeep_failovers_total | counter | Actors this node took over whose ownership record still named another node, freed because that node was fenced, released or replaced by a new process. See below. |
wasmkeep_history_collected_total | counter | Objects of actors’ history deleted from the bucket by garbage collection (ADR 0012). |
wasmkeep_history_collect_failures_total | counter | Garbage-collection passes that failed (a bucket error); the next snapshot tries again. Steadily rising: the bucket refuses deletes. |
wasmkeep_snapshot_upload_failures_total | counter | Snapshot uploads that were not acknowledged: the node gave the upload up, which does not say the bucket lacks it (an attempt given up can still land later, and is then an ordinary snapshot). A periodic snapshot has one attempt (one call to the bucket client, which may send the request again within it after an error answer), given up after 5 s plus a second per megabyte of the database, 10 s at most. One not acknowledged loses nothing and fails no event: the writes already uploaded are the history, and it is tried again once the actor has made one more write and 30 s have passed, then 8 writes later, then every 64 while it keeps failing (the node keeps these waits while an idle actor is out of memory; a node that takes the actor over, or one that restarted, tries at the actor’s first event there). Until one succeeds, a restore of that actor replays more writes; a database the bucket cannot take within 10 s counts here at every try. An actor’s first snapshot and the snapshot after a delete are sent again as a write is (an attempt is given up after 5 s plus a second per megabyte, the next one after twice that, three at most, the whole upload within 20 s plus that second per megabyte; the bucket client ends any single request after 30 s by default and does not send it again after that): the snapshot after a delete counts here and in wasmkeep_history_collect_failures_total, and does not delay the periodic one; an actor’s first snapshot counts here, and its event fails. Steadily rising: the bucket refuses or stalls large uploads. |
wasmkeep_signatures_refused_full_total{record} | counter | Calls from other nodes refused because this node’s record of used signatures (replay protection: 500,000 at once for calls, ordinary node calls; 50,000 for control, take and alarm hand-off calls, kept apart so a flood of other calls cannot block a drain or a move). The node logs such a refusal as a full record, apart from replays. Any rise: far more node calls a minute than expected. |
wasmkeep_websocket_owner_closes_total{reason} | counter | WebSockets closed with 1012 because this node no longer owned their actor, by how it found out: event (a message or close on one of them found another node owning the actor, or this draining node sending it to a peer), sweep (the owner sweep found another node holding a room that hibernated with sockets open here), lease_lapse (this node’s lease ran out) and drain (a draining node closing the sockets of hibernated rooms). Clients reconnect. A steady event or sweep rate means rooms often wake on another node than the one holding their sockets: hash the load balancer on the room’s path (front door). |
wasmkeep_websocket_sweep_reads_total | counter | Ownership records the owner sweep read: once a minute, one per room that hibernated with WebSockets open here, 5,000 a pass at most (the next pass goes on with the rest). A pass stops at its first failed read. Rising with the number of such rooms; each read is a bucket GET. |
Not there yet: per-tenant labels (one tenant per process until
multi-tenancy), traps by reason (wasmkeep_events_total{outcome="failed"}
and wasmkeep_cpu_budget_exceeded_total cover them for now) and parked
WebSockets (sockets are not parked yet; wasmkeep_websockets_open counts
them).
Bucket requests
Every bucket request a node makes is counted: leases, heartbeats, the alarm
index, replication, restores and following a deployed app. operation is
get, head, put (conditional writes included), delete, list,
copy, rename or multipart. status is:
| Status | Means |
|---|---|
ok | The request succeeded. |
not_found | The object did not exist. Routine: a lease or an app not created yet. |
precondition | A conditional write or read lost its condition (the object exists, or changed since it was read). Routine for leases: another node won the race. |
error | Anything else: unreachable bucket, refused credentials, throttling, a server error. |
Watch status="error"; the other two are part of normal operation. A
listing counts once per call, however many pages it took: S3 and R2 return
1,000 keys a page, so a restore of an actor with 10,000 objects in the
bucket counts 2 list requests here but bills about 22.
Failovers
wasmkeep_failovers_total counts, on the node that took them, actors
taken from another node whose node lease was fenced (or released with the
actor still recorded as its own, ttl plus grace earlier): that node died,
was cut off from the bucket, or stopped without releasing the actor (ADR
0020). An actor that moves because its
owner hibernated it, shut down, or gave it up is a hand-off (the owner
released it) and is not counted, nor is a node taking back its own actors
after a restart. Sum
it over nodes for the cluster; outside rolling restarts of crashed nodes it
should stay near zero.
Grafana dashboard
deploy/grafana/wasmkeep.json shows
every metric above except wasmkeep_classes, the guest log counters
(wasmkeep_guest_log_lines_total, wasmkeep_guest_log_dropped_total),
the history collection counters (wasmkeep_history_collected_total,
wasmkeep_history_collect_failures_total),
wasmkeep_snapshot_upload_failures_total,
wasmkeep_signatures_refused_full_total,
wasmkeep_websocket_owner_closes_total,
wasmkeep_websocket_sweep_reads_total,
wasmkeep_restore_failures_total, the listing and restore-read
histograms (wasmkeep_listing_seconds, wasmkeep_restore_read_seconds),
the node lease write and fence counters (wasmkeep_node_lease_writes_total,
wasmkeep_node_fences_total) and wasmkeep_requests_abandoned_total. To
see it on a running cluster, the
Compose example
starts three nodes with Prometheus and Grafana, the dashboard loaded.
Otherwise import it in Grafana (Dashboards, New, Import) and pick
your Prometheus data source; it needs Grafana 10 or later. It assumes
Prometheus adds an instance label to each node’s series, as it does by
default, and filters by node and class.
| Row | Panels |
|---|---|
| Overview | Nodes ready and not ready (with the reason), resident actors, open WebSockets, failovers over the time range. |
| Events | Events per second by class and outcome, the failed share by class, duration p50 and p99, events stopped by the host (wall clock, lost lease, CPU budget). |
| Actors and placement | Wakes and hibernations, instantiation p50 and p99, forwards by kind, alarm admission retries. |
| Bucket and leases | Bucket requests by operation and by status, durable write p50 and p99, bucket errors and node lease failures per node, failovers per node. |
A test (the_grafana_dashboard_uses_only_metrics_that_exist) fails if the
dashboard queries a metric the page does not have.
Alerts worth having
These are in deploy/prometheus/alerts.yml
as Prometheus rules, with one more for a node Prometheus cannot scrape
(up == 0 for a minute): a node that is down exports nothing, so none of
the rules below would say so. Load the file with rule_files: in
prometheus.yml and check it with promtool check rules after editing.
It assumes a scrape job named wasmkeep, and the rules on durable write
latency and on failed events wait 5 minutes before they fire. Two of the
rules below hold
“when no node was lost”: a rule cannot know that, so they fire on every
fence and failover, a deliberate kill included, and their text says so.
The Compose example
loads the file. A test (the_alert_rules_use_only_metrics_that_exist)
fails if a rule names a metric the page does not have.
wasmkeep_node_lease_failures_totalincreasing on any node: two failures in a row and the node drops every actor it holds.increase(wasmkeep_node_fences_total[15m])above 0 when no node was lost.histogram_quantile(0.99, rate(wasmkeep_durable_write_seconds_bucket[5m]))above 1 s.rate(wasmkeep_events_total{outcome="failed"}[5m])above 1% of all events for a class./_wasmkeep/readyfailing on any node for 2 minutes.rate(wasmkeep_bucket_requests_total{status="error"}[5m])above 0 for 5 minutes.increase(wasmkeep_failovers_total[15m])above 0 when no node was lost.
What actors print
What an actor writes to stdout or stderr (Rust’s println! and
eprintln!, console.log in TypeScript, print in Python, printf in C)
reaches the node’s log, one entry per line, as target wasmkeep::guest with
the actor’s address and the stream; a stateless component’s says
actor=stateless:
INFO wasmkeep::guest: order 42 shipped actor=acme/orders/42 stream="stdout"
- Limits:
--guest-log-ratelines a second per actor instance (50 by default, with bursts of twice that) and 1,000 a second for the whole node. Past them, lines are dropped and counted (wasmkeep_guest_log_dropped_total), and the actor’s next logged line is preceded by a warning saying how many were.--guest-log-rate 0discards everything. - Lines: a line is logged when its newline arrives; one left without
it is logged when the instance ends (hibernation, a trap). Lines are cut
at 4 KB, and control characters are escaped (
\u{1b}), so a guest cannot forge entries or colour a terminal. Writes never block or fail the guest. - Filtering: the usual
RUST_LOG, e.g.RUST_LOG=info,wasmkeep::guest=warnto keep only the drop warnings, orwasmkeep::guest=off. - Actors log what they choose to: whatever they print (a secret included) lands in the node’s log, and wherever that is shipped.
API reference
wasmkeep:actor@0.2.0, the interface between WasmKeep and every actor, in any language. Each entry gives the WIT definition, then its name in each SDK (Rust, TypeScript, Python, C, Zig). The SDKs add conveniences (typed rows, JSON, broadcast); see each SDK’s README.
The wasmkeep:actor package: everything an actor can call on the host, and
everything the host calls on an actor.
An actor is addressed as tenant/class/name. One component implements one
class; each distinct name is an independent instance with its own SQLite
database. The host delivers events (requests, alarms) to an instance one at
a time, in arrival order: an actor never runs concurrently with itself.
Versioning: 0.x minor versions may break guests; every change comes with a migration note (docs/adr). See docs/adr/0002-wasmkeep-actor-wit-v0.1.md and docs/adr/0003-websockets-wit-v0.2.md.
What an actor implements
HTTP requests arrive through the standard wasi:http/incoming-handler export (handle), which the TypeScript, Python, C and Zig SDKs expose as their fetch handler and the Rust SDK as http::Guest::handle. The world adds:
on-init
on-init: func() -> result<_, string>;
Runs before the first event an instance handles with this component version: once for a new actor, and again after each deploy. Use it to create or migrate tables. An error fails the event; it is retried with the next event.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
Actor::on_init | init() | Actor.init | wk_init | pub fn init |
on-alarm
on-alarm: func() -> result<_, string>;
Runs when the pending alarm is due. An error or trap schedules a retry.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
Actor::on_alarm | alarm() | Actor.alarm | wk_alarm | pub fn alarm |
on-websocket-open
on-websocket-open: func(conn: u64, path: string) -> result<_, string>;
A client upgraded a GET to path (the part after the actor’s name,
with its query). Return an error to refuse it: the connection is then
closed with code 1008 and the error as reason. It runs before the host
answers the upgrade (101), so the client is connected only once the
actor has accepted it. What the actor sends meanwhile waits in the
socket’s queue, with its usual cap (backpressure past 4 MiB). A
client that leaves before the 101 (or a node relaying for it, which
gives up after 30 s) still gets this event if it was already queued,
followed by on-websocket-close with code 1006.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
Actor::on_websocket_open | websocket.open() | Actor.websocket_open | wk_ws_open | pub fn wsOpen |
on-websocket-message
on-websocket-message: func(conn: u64, msg: message);
A message arrived on one of this actor’s connections.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
Actor::on_websocket_message | websocket.message() | Actor.websocket_message | wk_ws_message | pub fn wsMessage |
on-websocket-close
on-websocket-close: func(conn: u64, code: u16, reason: string);
A connection closed (by the client, by close, or by the host).
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
Actor::on_websocket_close | websocket.close() | Actor.websocket_close | wk_ws_close | pub fn wsClose |
Interface storage
The actor’s own SQLite database. Nothing else can read or write it.
Each call runs one statement. Outside an explicit transaction every
statement commits on its own. BEGIN … COMMIT across several calls
within one event is allowed; a transaction still open when the event ends
(or traps) is rolled back. A statement that fails in a way that rolls the
open transaction back (it ran past its deadline or the event’s SQL CPU
budget, say) ends that transaction: nothing of it is kept, and every
later storage statement of the event is refused with not-allowed
(but a ROLLBACK, which is what already happened, succeeds).
exec
exec: func(sql: string, params: list<value>) -> result<u64, sql-error>;
Runs a statement that returns no rows; returns the number of rows changed. Returns once the write is committed to the local database.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
storage::exec | storage.exec | storage.exec | wk_exec | wk.exec |
exec-durable
exec-durable: func(sql: string, params: list<value>) -> result<u64, sql-error>;
Like exec, but returns only once the write is durable: replicated to
the bucket when the node has one, fsynced locally when it does not.
Inside a transaction it takes only COMMIT (or END), and makes the
whole transaction durable; any other statement there is refused with
not-allowed before it runs. Outside one it does not take a
statement that opens or controls a transaction (BEGIN, SAVEPOINT):
run those with exec.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
storage::exec_durable | storage.execDurable | storage.exec_durable | wk_exec_durable | wk.execDurable |
query
query: func(sql: string, params: list<value>) -> result<rows, sql-error>;
Runs a statement and returns its rows.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
storage::query | storage.query | storage.query | wk_query | wk.query |
delete-self
delete-self: func();
Deletes this actor: its database is removed once the current event ends, and its pending alarm is cleared. The next event to this address starts a fresh actor.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
storage::delete_self | storage.deleteSelf | storage.delete_self | wk_delete_self | wk.deleteSelf |
Interface alarms
One pending alarm per actor. Alarms survive restarts and fire at least
once: an on-alarm that fails or traps is retried, so handlers must be
idempotent.
set-alarm
set-alarm: func(at: u64);
Schedules on-alarm at at (milliseconds since the Unix epoch),
replacing any pending alarm. A time in the past fires as soon as
possible.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
alarms::set_alarm | alarms.set | alarms.set | wk_alarm_set | wk.setAlarm |
get-alarm
get-alarm: func() -> option<u64>;
The pending alarm time, if any.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
alarms::get_alarm | alarms.get | alarms.get | wk_alarm_get | wk.getAlarm |
clear-alarm
clear-alarm: func();
Cancels the pending alarm, if any.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
alarms::clear_alarm | alarms.clear | alarms.clear | wk_alarm_clear | wk.clearAlarm |
Interface actors
Calls between actors of the same tenant.
request (record)
A fully buffered request, delivered to the callee’s
wasi:http/incoming-handler like any other request.
| Field | |
|---|---|
method: string | |
path: string | Path and query, starting with /. |
headers: list<tuple<string, string>> | |
body: list<u8> |
response (record)
| Field | |
|---|---|
status: u16 | |
headers: list<tuple<string, string>> | |
body: list<u8> |
call-error (variant)
| Case | |
|---|---|
invalid-address(string) | The address is not class/name, or the class does not exist. |
reentrant | The call would re-enter an actor already waiting in this call chain (including the caller itself), which would deadlock. |
too-large | The request or response exceeded the body limit (16 MB). |
timeout | The callee did not answer within the event’s wall-clock limit. |
failed(string) | The callee trapped, or the host could not deliver the call. |
actor-address (record)
An actor’s address. Within a tenant, class/name is what call
takes; the name is decoded (any text without / or control characters).
| Field | |
|---|---|
tenant: string | |
class: string | |
name: string |
call
call: func(address: string, request: request) -> result<response, call-error>;
Sends request to the actor at address (class/name, within the
caller’s tenant) and waits for its response. The caller’s own event
stays in progress meanwhile: other events to the caller wait.
The callee sees x-wasmkeep-caller: <tenant>/<class>/<name>, the name
percent-encoded (every byte but ASCII letters, digits and -._~), so
room 1 arrives as room%201. The host removes that header from
requests coming from outside, so the callee can trust it.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
actors::call | actors.call | actors.call | wk_call | wk.call |
self-address
self-address: func() -> actor-address;
This actor’s own address: an actor does not otherwise know its name
or class (requests arrive with /actor/<class>/<name> removed, and
alarms carry nothing). For links to itself, for telling other actors
who it is, and for anything keyed by its name.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
actors::self_address | actors.self() | actors.self_address | wk_self_address | wk.selfAddress |
Interface websockets
WebSocket connections to this actor.
The host accepts upgrades on the actor’s path and owns the sockets, so a connection stays open while the actor hibernates; the next message wakes it. A connection id is only valid in the actor it was opened on.
message (variant)
A WebSocket message.
| Case | |
|---|---|
text(string) | |
binary(list<u8>) |
send-error (variant)
| Case | |
|---|---|
unknown-connection | The connection is closed, or belongs to another actor. |
backpressure | The client is not reading fast enough; the message was dropped. |
send
send: func(conn: u64, msg: message) -> result<_, send-error>;
Queues msg on the connection.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
websockets::send | sockets.send | sockets.send | wk_send, wk_send_binary | wk.send, wk.sendBinary |
close
close: func(conn: u64, code: u16, reason: string);
Closes the connection with a close code (1000 = normal) and reason.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
websockets::close | sockets.close | sockets.close | wk_close | wk.close |
connections
connections: func() -> list<u64>;
This actor’s open connections, oldest first.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
websockets::connections | sockets.connections | sockets.connections | wk_connections | wk.connections |
Interface env
Configuration and secrets the operator gives an actor class: an API
key, a webhook URL, a feature flag. Values come, by name, last wins,
from the deployed version’s plain configuration (wasmkeep deploy --config CLASS=FILE), the app’s secrets sealed in the bucket (wasmkeep secrets set), and files on the node (wasmkeep serve --env CLASS=FILE),
never from the component. They are the same for every actor of the class
and read-only, and change only between events, never during one: when a
deploy or a secrets change lands (the next event runs in a new instance)
or the node restarts.
get
get: func(name: string) -> option<string>;
The value of name, or none if it is not set.
| Rust | TypeScript | Python | C | Zig |
|---|---|---|---|---|
env::get | env.get | env.get | wk_env | wk.env |
Interface types
Types shared by several interfaces.
value (variant)
A SQL value, mapping one to one onto SQLite’s storage classes.
| Case | |
|---|---|
null | |
integer(s64) | |
real(f64) | |
text(string) | |
blob(list<u8>) |
rows (record)
The result set of a query: column names, then one list of values per row, in column order.
| Field | |
|---|---|
columns: list<string> | |
values: list<list<value>> |
sql-error-kind (enum)
Why a statement failed.
| Case | |
|---|---|
syntax | The SQL did not parse, or referred to a missing table or column. |
constraint | A constraint failed (UNIQUE, NOT NULL, CHECK, FOREIGN KEY). |
bind | Parameter count or types do not match the statement. |
multiple-statements | The text held more than one statement; send one at a time. |
quota-exceeded | The database reached its size cap (default 1 GB); reads still work. |
not-allowed | The statement tried something actors may not do (ATTACH, a PRAGMA outside the allowed set, loading extensions). |
internal | The host failed (disk, I/O). The event should fail. |
sql-error (record)
| Field | |
|---|---|
kind: sql-error-kind | |
message: string | Human-readable detail, usually SQLite’s own message. |
Also available
The actor world includes the standard wasi:http/proxy world: outbound HTTP (wasi:http/outgoing-handler), clocks, randomness and stdio (what an actor writes to stdout and stderr goes to the node’s log, line by line, within --guest-log-rate). The wasi:cli interfaces most standard libraries import are present but empty: no arguments, environment or terminal. wasi:filesystem and wasi:sockets, which many toolchains import whether or not the code uses them, are linked but reach nothing: no directory is opened to the guest, and every socket and name lookup is refused. A component importing an interface the host does not provide is refused at deploy time.
Benchmarks
Every number here comes from a file written by ./bench/run.sh
(./bench/bucket.sh and ./bench/cluster.sh for the comparisons on a
bucket, ./bench/soak.sh for a real bucket), named in its section; sections that name no file use
bench/results/2026-11-04-db85998.csv
(commit db85998), except the failover row, which needs an S3 API and comes
from 2026-11-03-7060271.csv.
The short hash in a measurement file’s name (and in the text below)
identifies the build that was measured; it is a label, not a commit in this
repository. Anyone can rerun it and get their own file; numbers that do not come from
such a file do not belong in the docs.
Hardware: a shared cloud container (KVM, 4 vCPU Xeon @ 2.10 GHz, 16 GB),
Rust 1.99.1, Wasmtime 50.0.1, Node 22, workerd 2026-10-16. A shared VM is
noisy (we saw ±30% between runs on some rows), so read these as orders of
magnitude, not decimals. Compared with the previous file
(2bfc65f), workerd’s CPU per
request moved 2%, so the machine was comparable; WasmKeep’s Rust counter
went from 295 to 203 µs of CPU per request and from 9,897 to 15,356 req/s,
after the host stopped hopping to the blocking pool for its own
bookkeeping. The wake row came out slower (1.07 to 1.39 ms), but three
interleaved runs of both commits on the same machine overlapped (1.47 to
1.98 ms after, 1.73 to 1.87 ms before): that row is noise, not a change.
Publishable comparisons belong on dedicated
hardware: see the benchmark showdown.
WasmKeep against workerd, the same counter
The same actor in three runtimes: one counter object per name, its value in
SQLite, GET reads and POST increments.
- WasmKeep, Rust:
examples/counter, compiled ahead of time. - WasmKeep, TypeScript:
examples/ts-counteron QuickJS, the default engine, embedded in the component. - workerd: Cloudflare’s open-source Workers runtime with a SQLite-backed Durable Object,
bench/workerd/counter.js.
Each runtime runs as its own server process, and the same client
(bench/compare) drives it over loopback HTTP/1.1
keep-alive. Server memory and CPU time come from /proc/<pid>.
From bench/results/2026-11-06-351ea57.csv
(commit 351ea57, a clean tree, the first run with transparent huge
pages off for the server, see below; the previous run,
dec1a82, was within a few
percent on every row but memory). Hardware: an AMD Ryzen 9 7950X
workstation (16 cores, 32 threads), Rust 1.99.1, Wasmtime 50.0.1, Node 26,
workerd 1.20261016.1. Nothing else was running. Both runtimes kept their
actor data on tmpfs (/tmp), so no disk is involved on either side.
| Counter | WasmKeep, Rust | WasmKeep, TypeScript | workerd |
|---|---|---|---|
| Read, resident actor (p50 / p99) | 61 / 87 µs | 139 / 269 µs | 156 / 212 µs |
| Write, resident actor (p50 / p99) | 67 / 92 µs | 146 / 262 µs | 177 / 225 µs |
| First request to a new actor (p50) | 0.49 ms | 0.77 ms | 0.41 ms |
| Memory per resident actor | 250 KB | 741 KB | 277 KB |
| Server CPU time per request (8 clients) | 104 µs | 230 µs | 128 µs |
| Throughput, 8 clients, 1000 actors | 61,963 req/s | 29,712 req/s | 7,681 req/s* |
| Process start to first response | 51 ms | 202 ms | 51 ms |
* workerd runs one thread per process by design: Cloudflare scales it with many processes. Its throughput row measures one process, which is not a fair comparison. CPU time per request is the fair efficiency comparison, and the best proxy for energy we can measure here.
The same comparison on a shared 4-vCPU cloud VM
(2026-11-05-ae5416c.csv) gave
a different picture:
| On the 4-vCPU VM | WasmKeep, Rust | workerd |
|---|---|---|
| Server CPU time per request | 215 µs | 555 µs |
| Memory per resident actor | 251 KB | 267 KB |
| Read, resident actor (p50) | 213 µs | 411 µs |
How to read it
- The machine changes the answer. On the workstation, WasmKeep with Rust uses about 81% of workerd’s CPU per request; on the 4-vCPU VM it used 39%. workerd gains much more from fast cores (555 to 128 µs) than WasmKeep does (215 to 104 µs). A claim like “less than half the CPU” holds on one machine and not on the other, so quote both or neither.
- WasmKeep with Rust is still the fastest per request here. Reads and writes at p50 are about 2.6× faster than workerd’s, and the p99 gap is 2.4×.
- Memory per resident actor is about workerd’s: 250 KB against
277 KB, as on the VM (251 KB). Earlier workstation runs read 571 to
577 KB (files
cd48f41todec1a82), for every native language alike: this machine’s kernel has transparent huge pages set toalways, so each actor’s memory was backed by 2 MiB pages it mostly did not use.wasmkeep servenow turns them off for its own process (--transparent-huge-pages systemkeeps the kernel’s setting); CPU per request and latency did not move (three runs each way, within 2%). - TypeScript on WasmKeep costs more than Rust. On QuickJS, the default
engine, it uses about 2.2× the CPU of Rust and 3× the memory per
resident actor (1,000 resident actors: 936 MB), and about 1.8×
workerd’s CPU. Every actor carries its own JavaScript engine, without
V8’s JIT: for JavaScript-heavy workloads, workerd is the better home
(QuickJS takes 5.8 ms for the
js_workload below). TypeScript on WasmKeep is for teams that want WasmKeep’s model in their language. It is not a speed play. StarlingMonkey costs more, see below. - Writes are not exactly equivalent. A Durable Object’s storage may wait
for the disk before answering a write, while WasmKeep’s plain
execcommits without an fsync. On tmpfs neither waits for a disk, so the write row is closer to fair here than on a real disk. A durable write on WasmKeep (exec-durable, replicated) costs under 2 ms with an in-memory bucket and its data on tmpfs, see below. - Not measured yet: idle memory after hibernation (WasmKeep’s idle actors hold none), wake latency per runtime, and workerd with several processes. These are next for the comparison harness.
WasmKeep against celld, every write in a bucket
celld runs Durable Objects on a bucket: each object’s SQLite changes go to S3, much like WasmKeep’s replication. Here both runtimes serve the same counter on the same local bucket:
- WasmKeep, durable:
examples/counterbuilt with itsdurablefeature, soPOSTcommits withexec-durableand answers once the write is in the bucket. WasmKeep runs as a one-node cluster, since a node with a bucket is a cluster node. - WasmKeep, plain
exec: the default build of the same counter. ItsPOSTanswers before the write reaches the bucket, so it is not equivalent to the other two columns and is shown apart. - celld: the workerd counter
(
bench/workerd/counter.js, unchanged), deployed withcelld deployto one celld node. One node waits for the bucket on every write (CELLD_DURABILITY=bucket, set explicitly).
Run by bench/bucket.sh (compare --bucket,
compare-bucket/ rows) at commit 488369d, a clean tree, five times with
the runtimes’ data on tmpfs and five times on disk (btrfs on an encrypted
NVMe SSD), each run on an emptied bucket. Each cell is the median of five
runs, with the lowest and highest in brackets; the files are
…-data-tmpfs-r1.csv
to -r5.csv and
…-data-disk-r1.csv
to -r5.csv. Hardware: the AMD Ryzen 9 7950X workstation above, Rust
1.99.1, Wasmtime 50.0.1, celld v0.6.3 (the release binary, its build
provenance checked by bench/celld/fetch.sh).
The bucket is MinIO on loopback with its data on tmpfs, so a write to it
costs a local round trip and no disk. Transparent huge pages are off for
both servers, as wasmkeep serve sets for itself and the harness sets for
celld: a run that left celld on the kernel’s always
(e3856a5) read 609 MB at 1000
actors instead of 395 MB.
| Counter on a bucket, data on tmpfs | WasmKeep, durable | celld | WasmKeep, plain exec (not equivalent) |
|---|---|---|---|
| Write, resident actor, p50 | 0.53 ms (0.53–0.54) | 0.93 ms (0.93–0.94) | 0.46 ms (0.46–0.46) |
| Write, p99 | 1.15 ms (1.12–1.24) | 1.25 ms (1.24–1.29) | 1.33 ms (1.16–1.49) |
| Read, resident actor, p50 | 64 µs (61–71) | 117 µs (114–128) | 64 µs (59–66) |
| First request to a new actor, p50 | 3.10 ms (3.08–3.13) | 2.99 ms (2.95–3.05) | 2.62 ms (2.59–2.64) |
| Memory per resident actor | 272 KB (272–274) | 293 KB (292–294) | 247 KB (247–248) |
| Server memory, 1000 resident actors | 333 MB (332–335) | 395 MB (393–396) | 308 MB (308–309) |
| Server CPU time per request (8 clients) | 393 µs (389–397) | 475 µs (465–485) | 382 µs (373–385) |
| Throughput, 8 clients, 1000 actors | 10,823 req/s (10,750–10,903) | 6,650 req/s (6,514–6,776) | 25,712 req/s (24,746–26,016) |
| Process start to first response | 15.08 s* | 79 ms (77–79) | 15.08 s* |
| Counter on a bucket, data on disk | WasmKeep, durable | celld | WasmKeep, plain exec (not equivalent) |
|---|---|---|---|
| Write, resident actor, p50 | 2.31 ms (2.23–2.32) | 2.55 ms (2.49–2.57) | 0.54 ms (0.53–0.54) |
| Write, p99 | 6.02 ms (5.95–6.14) | 3.64 ms (3.20–4.26) | 5.81 ms (5.78–5.84) |
| Read, resident actor, p50 | 63 µs (62–66) | 116 µs (113–130) | 64 µs (60–67) |
| First request to a new actor, p50 | 19.4 ms (19.3–19.6) | 17.1 ms (16.9–17.2) | 16.9 ms (16.6–16.9) |
| Memory per resident actor | 273 KB (273–274) | 289 KB (288–290) | 247 KB (246–248) |
| Server memory, 1000 resident actors | 334 MB (333–334) | 388 MB (387–390) | 308 MB (308–310) |
| Server CPU time per request (8 clients) | 683 µs (661–691) | 800 µs (775–835) | 692 µs (686–708) |
| Throughput, 8 clients, 1000 actors | 1,980 req/s (1,974–2,032) | 2,087 req/s (2,003–2,198) | 9,520 req/s (9,459–9,670) |
| Process start to first response | 15.09 s* | 94 ms (94–97) | 15.09 s* |
* Includes the 15 s a WasmKeep cluster node’s first start waits before it listens, checking that no other process runs with its node id (cluster); a restart after a fence or a clean stop waits less. celld does not wait.
How to read it
- Durable against durable, WasmKeep answers a write sooner at p50, on
tmpfs and on disk. 0.53 against 0.93 ms on tmpfs and 2.31 against
2.55 ms on disk. At p99 the two are close on tmpfs (1.15 against
1.25 ms) and celld is ahead on disk (3.64 against 6.02 ms). Without a
bucket the same durable write takes 102 µs
(
compare/wasmkeep-rust-durablein2026-11-10-f163ba0.csv). - On disk a write takes 1.6 to 1.8 ms longer in both, and throughput is close, celld about 5% ahead. Eight clients completed 1,980 writes per second on WasmKeep against 2,087 on celld at the median; the runs of each overlap (1,974 to 2,032 against 2,003 to 2,198). On tmpfs WasmKeep completed 1.6× celld’s (10,823 against 6,650); celld runs as one multi-threaded process, and this row measures that process.
- A real bucket changes the scale again. MinIO on tmpfs and loopback answers in well under a millisecond. A remote S3 bucket takes tens of milliseconds per write, which would dominate both durable write rows; the read, memory and CPU rows do not touch the bucket.
- WasmKeep uses about 85% of celld’s CPU per request (83% on tmpfs, 85% on disk), 93 to 95% of its memory per resident actor and 84 to 86% of its process memory at 1000 actors, and reads about twice as fast at p50.
- Plain
execon a bucket is barely faster per actor than durable on tmpfs, and much faster on disk. Its response does not wait for the bucket, but an actor uploads an event’s changes before it runs its next event, so the next request to the same actor waits instead. Across many actors the uploads overlap: its throughput is 2.4× the durable build’s on tmpfs and 4.8× on disk. - One node only. With two or more nodes, celld by default
(
CELLD_DURABILITY=fleet) acknowledges a write once a follower node has it on disk, or once the bucket has it, whichever comes first. WasmKeep has no such mode: a durable write always waits for the bucket. Three nodes are measured below. - Start time is a design difference. celld answers 77 to 97 ms after
it starts, with its wait for the rest of a fleet turned off for this
one-node run (
CELLD_READY_FLEET_GATE_MS=0); a WasmKeep cluster node’s first start waits 15 s, by design. A single node without a bucket does not wait (51 ms above).
Three nodes: WasmKeep against celld
Three nodes of each runtime sharing one bucket, run by
bench/cluster.sh (compare --cluster): the same
counter for the steady-state rows, then each node in turn killed under
writes. Rows are cluster3/..., cluster3-wipe/... for the takeovers
repeated with the killed node’s directory removed, and
cluster3-control/... for a run that must lose writes. Durable here means
what each runtime means by it:
- WasmKeep, durable (
exec-durable): a write answers once the bucket holds it, on any number of nodes. - celld, fleet (
CELLD_DURABILITY=fleet, celld’s default with two or more nodes): a write answers once a follower node has it on disk, or once the bucket has it, whichever comes first. Up to two followers per object; the bucket upload follows. - celld, bucket (
CELLD_DURABILITY=bucket): a write answers once the bucket holds it, as on WasmKeep. - WasmKeep, plain
exec: answers before its upload, not equivalent, shown apart.
Both runtimes keep their default lease timings (a dead node’s actors move
after 15 s on WasmKeep, 10 s on celld) and talk plain HTTP between nodes,
since celld has no TLS between peers. Every request goes to a node picked
at random, so two in three are forwarded to the actor’s owner. WasmKeep
nodes run with --max-instances 2000, --max-connections 512 and
--idle-timeout-secs 3600, so no actor hibernates during a run; celld
nodes run with transparent huge pages off, as WasmKeep turns them off for
itself.
The check for lost writes. After the steady-state rows, each node in turn is killed with SIGKILL while 90 actors are written every 20 ms, one client per actor retrying every 100 ms through a random node. These actors are ledgers, not counters: every attempt writes a token of its own, never sent twice. After each restart, and once more at the end, every ledger’s tokens are read back: an acknowledged token that is missing is a lost write, and a token held twice was applied twice. An actor whose longest wait between two acknowledgements after the kill is over a second was on the dead node, and the end of that wait is when it was writable again. Three more runs go with it:
- The killed node’s disk removed (
cluster3-wipe/): the same kills, with the node’s directory deleted before it restarts, so nothing that node alone held survives. - A control that must lose writes (
cluster3-control/): plainexecon nodes that reach the bucket through a relay; half a second before the kill the relay cuts the node off, so each of its actors acknowledges a write the bucket never gets, then the node is killed and its directory removed. If this run loses nothing, the script fails and writes no file. - Five runs of everything, each on an emptied bucket, once with every node’s data on disk (btrfs on an encrypted NVMe SSD) and once on tmpfs, where an fsync costs nothing.
Hardware: the workstation above, the three nodes of a target as
processes on it, one target at a time; the bucket is the same local MinIO,
its data on tmpfs. Commit c556110, a clean tree. Each cell is the median
of five runs, with the lowest and highest in brackets; the files are
…-nodes-disk-r1.csv
to -r5.csv and
…-nodes-tmpfs-r1.csv
to -r5.csv.
Lost writes
The same in both sets of five runs, on disk and on tmpfs:
| Per run: 3 kills, 90 ledgers written through each | WasmKeep, durable | celld, fleet | celld, bucket | WasmKeep, plain exec (may lose, by design) |
|---|---|---|---|---|
| Acknowledged writes checked (data on disk) | about 71,700 | about 41,700 | about 60,500 | about 86,400 |
| Acknowledged writes lost | 0 in every run | 0 in every run | 0 in every run | 0 to 3 a run |
| Lost, the killed node’s disk removed | 0 in every run | 0 in every run | 0 in every run | not run |
| Writes applied twice | 0 | 0 | 0 | 0 |
| Ledgers that could not be read back | 0 | 0 | 0 | 0 |
| Control: lost, of about 86,000 to 89,000 (must not be 0) | 86 to 90 a run |
Timings, every node’s data on disk
| Three nodes, data on disk | WasmKeep, durable | celld, fleet | celld, bucket | WasmKeep, plain exec (not equivalent) |
|---|---|---|---|---|
| Write, through a random node, p50 | 2.30 ms (2.29–2.31) | 5.24 ms (5.22–5.30) | 2.68 ms (2.63–2.72) | 0.55 ms (0.55–0.56) |
| Write, p99 | 5.89 ms (5.85–6.20) | 13.5 ms (12.2–22.9) | 4.17 ms (3.31–4.49) | 5.93 ms (5.81–6.05) |
| Read, through a random node, p50 | 92 µs (91–93) | 218 µs (215–224) | 220 µs (211–221) | 93 µs (91–96) |
| First request to a new actor, p50 | 19.1 ms (19.1–19.4) | 20.6 ms (20.5–21.0) | 17.6 ms (17.2–18.1) | 17.2 ms (16.8–17.4) |
| Memory, three nodes, 1000 resident actors | 459 MB (455–461) | 519 MB (514–544) | 520 MB (518–524) | 432 MB (432–435) |
| Server CPU time per request (8 clients) | 952 µs (938–975) | 1,630 µs (1,628–1,846) | 958 µs (951–967) | 709 µs (681–716) |
| Throughput, 8 clients, 1000 actors | 1,710 req/s (1,700–1,733) | 373 req/s (353–381) | 1,857 req/s (1,822–1,888) | 13,416 req/s (12,205–14,695) |
| Writable again after its node died, p50 | 15.0 s (14.7–15.3) | 9.1 s (7.6–9.6) | 9.4 s (9.3–9.5) | 14.9 s (13.8–15.0) |
| Writable again, the slowest actor | 16.3 s (16.2–16.4) | 10.1 s (9.8–10.1) | 9.7 s (9.7–9.9) | 17.0 s (15.2–17.0) |
| Actors that were on the dead node (of 270) | 90 | 90 | 90 | 90 to 91 |
| Restarted node ready (at most) | 1.56 s | 0.15 s (0.14–0.18) | 0.13 s (0.13–0.19) | 1.56 s |
| Cluster start to first response | 15.1 s* (15.1–18.1) | 0.12 s (0.11–0.14) | 0.12 s (0.11–0.27) | 15.2 s* (15.1–18.1) |
* Includes the 15 s node-id check of a first start (cluster). A node restarted after a kill waits less: the surviving nodes have fenced the old process by then.
Timings, every node’s data on tmpfs
The same runs with only the nodes’ data directories moved to tmpfs. An fsync costs nothing there, so this is not a durability setup: it shows what is left of each write once the disk is taken out.
| Three nodes, data on tmpfs | WasmKeep, durable | celld, fleet | celld, bucket | WasmKeep, plain exec (not equivalent) |
|---|---|---|---|---|
| Write, through a random node, p50 | 0.59 ms (0.58–0.60) | 0.68 ms (0.61–0.70) | 1.04 ms (1.04–1.07) | 0.49 ms (0.48–0.50) |
| Write, p99 | 1.55 ms (1.16–2.21) | 1.16 ms (1.04–1.38) | 1.41 ms (1.36–1.48) | 1.07 ms (1.02–1.25) |
| Read, through a random node, p50 | 95 µs (92–96) | 217 µs (213–226) | 218 µs (215–231) | 94 µs (93–97) |
| First request to a new actor, p50 | 3.21 ms (3.18–3.32) | 2.66 ms (2.59–2.85) | 3.17 ms (3.13–3.24) | 2.73 ms (2.71–2.76) |
| Memory, three nodes, 1000 resident actors | 460 MB (460–461) | 545 MB (540–548) | 533 MB (531–534) | 433 MB (430–435) |
| Server CPU time per request (8 clients) | 509 µs (504–516) | 1,288 µs (1,117–1,299) | 627 µs (625–634) | 460 µs (456–463) |
| Throughput, 8 clients, 1000 actors | 9,137 req/s (8,967–9,161) | 4,238 req/s (4,193–4,532) | 5,888 req/s (5,806–5,907) | 21,331 req/s (20,770–21,622) |
| Writable again after its node died, p50 | 15.0 s (13.3–15.1) | 9.7 s (7.5–9.9) | 9.2 s (9.1–9.5) | 14.1 s (14.1–15.1) |
How to read it
- No acknowledged durable write was lost, in ten runs of each target.
Sixty kills each for WasmKeep durable, celld fleet and celld bucket (three
with the node’s files kept and three with them removed, in each run),
every acknowledged token found afterwards. The control lost writes in
every run, so the check does find a loss; and plain
exec, which may lose writes with its node, lost 0 to 3 of about 86,000 in a run without any help from the relay. - What the check still does not test. Removing the killed node’s files tests what that node alone held; the followers on the surviving nodes keep their page cache, so an acknowledgement that relies on a follower’s fsync (celld fleet) is not tested against a power loss. All three nodes are processes on one machine. The soak checks WasmKeep’s durable writes on separate machines, over hours.
- Some unacknowledged writes were applied, which is allowed. A write whose client got no answer (the request failed or timed out during a takeover) may still have been applied: 8 to 25 a run on WasmKeep durable, 13 to 26 on celld bucket and 34 to 100 on celld fleet, with data on disk. The client retries with a new token, so none is counted twice.
- Waiting for the bucket in both, on disk, the two are close and celld is ahead on throughput. WasmKeep answers a write sooner at p50 (2.30 against 2.68 ms) and later at p99 (5.89 against 4.17 ms); eight clients, each waiting for its answer, completed 1,710 writes per second against 1,857, about 8% apart where runs of one target differ by 2 to 4%, at the same CPU per write (952 against 958 µs).
- Where the nodes keep their data changes the order. With data on tmpfs WasmKeep completed 9,137 durable writes per second against 5,888 for celld in bucket mode, at 509 against 627 µs of CPU. A durable write takes 1.75 ms longer than a plain one on disk (2.30 against 0.55 ms) and 0.10 ms longer on tmpfs (0.59 against 0.49 ms). These runs change only where the data is; they do not say which step of a write the disk slows.
- celld’s fleet mode is its slowest on a shared disk, and that is not explained. 5.24 ms at p50 and 373 writes per second, against 2.68 ms and 1,857 in bucket mode, the same in five runs. An acknowledgement that takes whichever of the followers and the bucket comes first should not be slower than waiting for the bucket. Here the owner and its followers share one device; whether that is the cause is not measured, and nodes with a disk each would tell. On tmpfs fleet mode answers a write sooner than bucket mode (0.68 against 1.04 ms) but completes fewer (4,238 against 5,888 a second) at twice the CPU.
- WasmKeep reads faster and holds less memory. Reads take 92 against about 220 µs at p50 (both forwarded two times in three), and three nodes hold 1000 actors in 459 against about 520 MB.
- Takeover is close to each one’s lease timing. A dead node’s actors are writable again after about 15 s on WasmKeep and 9 to 10 s on celld, close to each one’s default (15 s and 10 s). Neither side was tuned. A restarted celld node is ready in about 0.15 s; a WasmKeep node in 1.6 s.
- Not measured here: nodes on separate machines, each with its own
disk (here the network between nodes is loopback and the disk is
shared), a cloud bucket, and mutual TLS between WasmKeep nodes
(measured above). An
earlier single run with a weaker check for lost writes is in
2026-11-10-d2dea43-cluster-minio-tmpfs.csv.
The same counter in every language
The same actor contract (read, increment, persist) in every guest language
WasmKeep supports, each runtime its own server process driven by the same
client as above. From
bench/results/2026-11-06-351ea57.csv
(commit 351ea57, the workstation and run of the workerd table above).
TypeScript is on QuickJS, the default engine; StarlingMonkey is the
--engine starlingmonkey build of the same source.
| Counter | Rust | Zig | C | Go (TinyGo) | TypeScript | TS on StarlingMonkey | Python |
|---|---|---|---|---|---|---|---|
| Component | 81 KB | 30 KB | 170 KB | 151 KB | 1.7 MB | 13.4 MB | 18.7 MB |
| Server CPU per request (8 clients) | 104 µs | 107 µs | 110 µs | 248 µs | 230 µs | 368 µs | 259 µs |
| Throughput, 8 clients | 61,963 req/s | 59,649 req/s | 58,840 req/s | 28,160 req/s | 29,712 req/s | 19,708 req/s | 26,751 req/s |
| Read, resident actor (p50 / p99) | 61 / 87 µs | 72 / 116 µs | 65 / 114 µs | 73 / 742 µs | 139 / 269 µs | 227 / 334 µs | 142 / 212 µs |
| Write, resident actor (p50 / p99) | 67 / 92 µs | 76 / 111 µs | 72 / 124 µs | 85 / 3,375 µs | 146 / 262 µs | 241 / 329 µs | 151 / 214 µs |
| Memory per resident actor | 250 KB | 242 KB | 244 KB | 390 KB | 741 KB | 3.4 MB | 7.8 MB |
| First request to a new actor (p50) | 0.49 ms | 0.51 ms | 0.51 ms | 0.70 ms | 0.77 ms | 1.2 ms | 1.8 ms |
| Process start to first response | 51 ms | 51 ms | 51 ms | 52 ms | 202 ms | 704 ms | 705 ms |
The Go column is the counter from before the Go SDK (151 KB; 162 KB with it): the same host calls without the wrapper.
The same table on the shared 4-vCPU cloud VM
(2026-11-05-ae5416c.csv,
without QuickJS) had the same order at 1.5 to 3.4× this machine’s CPU per
request: Rust 215 µs, Zig 214, C 222, Go 364, TypeScript on StarlingMonkey
855, Python 859.
How to read it
- Rust, Zig and C cost the same. Their CPU per request is within 6% of each other, which is run-to-run noise here: most of a request’s cost is the host (HTTP, entering and leaving Wasmtime, SQLite), not the guest. Zig’s smaller component changes deploys and the first compile, not requests.
- Zig needs its stack set. Zig’s default wasm stack is 16 MiB, and a
component reserves it in every actor’s memory: the Zig counter took
2.66 MB per resident actor in an earlier run (with huge pages on),
against 0.57 MB for C. The examples now build with
--stack 1048576, as Rust’s 1 MiB (sdks/zig). - TinyGo costs about 2.4× the CPU of the native three, with long tails. Its median latency is close to theirs, but its p99 is 10× (reads) to 40× (writes) its median: TinyGo’s garbage collector runs inside the request. Memory per actor is 1.6× theirs.
- The embedded runtimes cost 2.2 to 3.5× the CPU. QuickJS (230 µs) and CPython (259 µs) land close together, StarlingMonkey (368 µs) above them. Memory per resident actor is 3× (QuickJS), 14× (StarlingMonkey) and 31× (CPython) Rust’s: each actor carries its own engine or interpreter. 1,000 resident Python actors took 8.4 GB.
- Python’s CPU per request fell the most from the VM (859 to 259 µs, 3.4×, against 2.1× for Rust): CPython’s interpreter loop gains more from this machine’s caches and clock than the host does.
- Component sizes are from each example’s
build.sh(languages).
TypeScript on QuickJS against StarlingMonkey
The same TypeScript counter (examples/ts-counter),
one source file built twice: with QuickJS, the default engine since
ADR 0018
(npm run build), and with StarlingMonkey (npm run build:starlingmonkey,
see languages), next to the Rust
counter. From the same file and run as the tables above,
2026-11-06-351ea57.csv. The
js_work rows time the counter’s /work path
(work.ts): plain JavaScript
(building 200 records, a JSON round trip, filtering, sorting, string
formatting, a regex, a hash; four rounds), one request at a time, so its
latency is the engine’s time.
| Counter | Rust | TypeScript, QuickJS | TypeScript, StarlingMonkey |
|---|---|---|---|
| Component | 81 KB | 1.7 MB | 13.4 MB |
| Memory per resident actor | 250 KB | 741 KB | 3.4 MB |
| Server memory, 1,000 resident actors | 301 MB | 936 MB | 4.17 GB |
| Server CPU per request (8 clients) | 104 µs | 230 µs | 368 µs |
| Throughput, 8 clients, 1,000 actors | 61,963 req/s | 29,712 req/s | 19,708 req/s |
Plain JavaScript work, js_work (p50 / p99) | 5.76 / 6.23 ms | 3.76 / 4.21 ms | |
| First request to a new actor (p50) | 0.49 ms | 0.77 ms | 1.22 ms |
| Process start to first response (empty compile cache) | 51 ms | 202 ms | 704 ms |
The comparison that decided the default
(2026-11-06-dec1a82.csv,
with huge pages on: 1.06 MB against 3.7 MB per actor) had the same CPU
and js_work rows within 7%.
How to read it
- QuickJS takes 4.6× less memory per resident actor than StarlingMonkey (741 KB against 3.4 MB), 3× Rust’s. A node holding 1,000 TypeScript actors needs 936 MB instead of 4.17 GB.
- It costs less CPU on the counter: 230 µs per request against 368 µs (37% less), 2.2× Rust instead of 3.5×. Each request’s cost here is mostly entering a fresh instance, the host and SQLite, and a smaller engine is cheaper to instantiate.
- StarlingMonkey is 1.5× faster on plain JavaScript work (3.76 ms
against 5.76 ms). Neither engine has a JIT; SpiderMonkey’s interpreter
is faster than QuickJS’s. An actor that spends milliseconds computing in
JavaScript per event is the case for
--engine starlingmonkey(or for Rust). - Components are 8× smaller (1.7 MB), which shortens deploys and the first compile on a node (202 ms against 704 ms from start to first response with an empty cache).
- What QuickJS does not provide (Web APIs such as
fetch,URL,crypto,console, timers) is listed in languages.
Between nodes: plain HTTP against mutual TLS
What a hop between two nodes costs, and what mutual TLS between
nodes adds to it
(ADR 0011). From
bench/results/2026-11-05-d8e9ab7.csv
(commit d8e9ab7, the cluster/* rows), with an earlier run of the same
suite,
2026-11-05-b07a9d7.csv, for
the spread, both on different hardware from the sections above: a
workstation (AMD Ryzen 9 7950X, 16 cores / 32 threads, 64 GB), Rust
1.99.1, data in a RAM-backed /tmp (tmpfs). Compare
these rows with each other, not with the tables above.
The suite (benches/cluster.rs)
runs two pairs of nodes in one process over loopback, each pair sharing an
in-memory bucket: one pair plain, one with cluster listeners. In the TLS
pair every node publishes a plain address nobody listens on, so a hop in
plaintext fails the run instead of being measured. One client, one
request at a time, kept alive; nodes pool their connections to each other.
| One request, median | Plain | Mutual TLS | TLS adds (earlier run) |
|---|---|---|---|
| Counter GET sent to the owner (no hop) | 63.2 µs | ||
| The same GET sent to the other node, which forwards it | 106.3 µs | 115.1 µs | 8.8 µs (11.8 µs) |
actors.call from an actor on one node to an actor on the other | 133.7 µs | 155.1 µs | 21.4 µs (15.0 µs) |
The earlier run predates the change that made forwarding drop hop-by-hop headers, which only adds a header filter to these kept-alive rows. Between the runs, rows moved by up to 7 µs either way: read the TLS cost as a range, not a decimal.
How to read it
- A hop costs about 40 µs on loopback in plain HTTP (106.3 against 63.2 µs), about 50 µs over TLS. On a real network the round trip between the nodes comes on top, the same with or without TLS.
- TLS adds about 10 to 20 µs per hop (9 to 12 µs to a forwarded request, 15 to 21 µs to a call, across the two runs): encryption both ways, and the checks ADR 0011 makes on every request (the peer’s pin checked again, and the answer checked before it is delivered). The call row also carries the signature and the body’s hash, plain or not.
- Not measured: a new connection. Each row reuses a pooled connection,
so a full TLS 1.3 handshake (with a client certificate, and no session
resumption by design) is not in these numbers. Connections are pooled
and kept alive, also for clients that send
Connection: close(forwarding drops hop-by-hop headers); they are rebuilt when a peer’s pin changes or its heartbeat lapses. - Only one request at a time: throughput and CPU per request between nodes, and many clients at once, are not measured yet.
Restore against history length
How long a node takes to restore an actor it has never run (as on
failover, or when an actor wakes on another node) as the actor’s history
in the bucket grows, and what garbage collection changes. From
bench/results/2026-11-05-4c8b21a.csv
(commit 4c8b21a, the restore/* and list/* rows; the first run,
before collection existed, is
2026-11-05-cd48f41.csv), on the same
workstation as the section above (AMD Ryzen 9 7950X, 16 cores / 32
threads), Rust 1.99.1, data in a RAM-backed /tmp (tmpfs), and an
in-memory bucket. Compare these rows with each other, not with the
tables above.
The suite (benches/restore.rs)
runs two nodes sharing the bucket over loopback. A writer gives one
counter N replicated writes (one segment each, plus one for on-init); a
restorer is then asked for fresh actors whose prefix holds a copy of that
history. Each timed request takes the lease, lists the prefix, plans,
downloads and applies the chain, and answers a counter GET. Three shapes
of the same history:
- As written: the owner uploads a snapshot every 64 segments, so a restore replays fewer than 64 segments, but nothing is ever deleted, so the prefix keeps every segment and snapshot.
- Without periodic snapshots (removed from the copy, the first kept): every segment is replayed. This is what an actor whose periodic snapshots kept failing would leave (a failed one is counted and tried again one segment and 30 s later, then less and less often, down to once every 64 segments).
- After garbage collection (ADR 0012):
what the server’s rule (
collectable) leaves after the last periodic snapshot once the retention window has passed: that snapshot, the segment at its position and the segments after it.
| Writes (N) | Objects, as written | Replayed | Restore, as written | Restore, no periodic snapshots (replays N+1) | Listing, as written | Objects, collected | Restore, collected | Listing, collected |
|---|---|---|---|---|---|---|---|---|
| none | 0 | 0 | 0.78 ms | |||||
| 10 | 12 | 11 | 1.06 ms | 1.06 ms | 1.1 µs | 12 | 1.06 ms | 1.1 µs |
| 100 | 103 | 37 | 1.93 ms | 4.26 ms | 9.1 µs | 39 | 1.92 ms | 3.4 µs |
| 1,000 | 1,017 | 41 | 2.56 ms | 35.0 ms | 94.8 µs | 43 | 2.07 ms | 3.7 µs |
| 10,000 | 10,158 | 17 | 6.13 ms | 342 ms | 1.72 ms | 19 | 1.23 ms | 1.7 µs |
Medians. The baseline with no history, run again after all the other restore rows, was 0.78 ms both times: restored actors piling up on the node do not inflate the later rows. At 10 writes nothing is collected: the only snapshot is the first one, with no segment at its position.
How to read it
- Periodic snapshots bound the replay, collection bounds the listing. As written, a restore replays at most 63 segments whatever the history’s length, but the prefix grows by one object per write and one per 64 writes: at 10,000 writes a restore takes 6.1 ms against 2.6 ms at 1,000 while replaying fewer segments (17 against 41), because it lists the whole prefix twice and listing 10,158 objects takes 1.7 ms in memory (restores now list the prefix once; not remeasured).
- After collection the prefix holds at most 65 objects, whatever the history’s length (a snapshot and up to 64 segments): at 10,000 writes the restore takes 1.2 ms instead of 6.1 ms, and listing takes 1.7 µs instead of 1.7 ms. The collected rows stay within the range of small histories (1.1 to 2.1 ms here, depending on how many segments follow the last snapshot). On S3 or R2 the difference is larger: one listing page instead of eleven.
- A replayed segment costs about 34 µs here (342 ms for 10,001 segments): one GET, decoding and applying it. Each counter write changes one page, so this is the fixed cost per segment; segments with more pages cost more.
- Not measured: a real bucket. The in-memory bucket answers a listing
in one call; S3 and R2 page listings by 1,000 keys, so at 10,158 objects
each listing is 11 requests (22 per restore in the version measured
above, which listed twice), before the first download.
At 20 to 50 ms per request that would add about 0.4 to 1.1 s to a
restore (an estimate from the request count, not a measurement), and
every replayed segment is one more GET: 82 ms each at the median next
to OVHcloud Object Storage, so 1 to 6 s for up to 64 segments
(measured). LIST and GET counts against a
real bucket, peak RSS during restore, larger databases and pages per
segment, and many actors at once are not measured either. The
collected rows are histories collected by the rule, copied into the
bucket; the server’s own passes (
--history-retention-secs, an hour by default) are tested intests/cluster/, not timed here.
Restart and alarm recovery against actor count
How a node’s restart and the recovery of a dead node’s alarms scale with
the number of actors. From
bench/results/2026-11-05-4c10a33.csv
(commit 4c10a33, the restart/* and alarm_recovery/* rows), on a
workstation (AMD Ryzen 9 7950X, 16 cores / 32 threads, 64 GB), Rust
1.99.1, data in a RAM-backed /tmp (tmpfs), an in-memory bucket,
and other work running on the machine at the same time. Compare these
rows with each other, not with the tables above.
Restarting a node
benches/restart.rs: one
standalone node holds N test actors, each created by a request that set
an alarm an hour out, so each database is as an actor leaves it (36 KB
on disk per actor with its WAL files: 3.7 GB at 100,000). The node is
stopped as a crash stops it and started again on the same directory.
| Actors on disk | Start-up alarm scan | Start to first answer from an actor | Start until every overdue alarm ran |
|---|---|---|---|
| 1,000 | 108 ms | 3.9 ms | 257 ms |
| 10,000 | 1.06 s | 3.9 ms | 3.36 s |
| 100,000 | 11.5 s | 4.0 ms | not measured |
Medians. The scan column is Storage::pending_alarms alone (the node
runs it at start-up on a blocking thread, then arms what it found). The
last column makes every actor’s alarm overdue while the node is down, and
counts until each has run on-alarm once (it includes the scan).
- A restart serves at once, whatever the population. The listener does not wait for the scan: 4 ms from start to an actor’s answer at 100,000 actors as at 1,000.
- The scan is linear, about 110 µs per database, on one thread. It opens every database (with the same set-up as an actor’s: WAL mode, pragmas, the host’s tables) to read one row. Until it finishes, no alarm from before the restart is armed: at 100,000 actors an alarm due during the first 11.5 s fires up to 11.5 s late. At this rate a million databases would take about two minutes (an extrapolation; not measured, and 37 GB at this size). A slower disk than tmpfs adds its own cost per open.
- Overdue alarms after a restart run at about 3,000 a second (10,000 in 3.4 s, of which 1.1 s is the scan): each one wakes its actor, runs the event, and the actor stays resident until the pool needs room.
Alarm recovery after a node dies
benches/alarm_recovery.rs:
three nodes share the bucket. Node a creates N test actors, each with
an alarm at the same instant T, and is killed once the bucket holds
them and their alarm index entries (no lease released). Nodes b and c
never ran those actors: their sweeps find the alarms in the bucket’s
index, and each delivery takes the actor over, restores it and runs
on-alarm. The time is from T until every alarm has run and the index
is empty. Timings are a tenth of the defaults (lease ttl 1 s, grace
0.5 s, heartbeat 0.3 s, sweep every 1 s instead of 10 s): an alarm can be
delivered by another node once it is overdue by a lease period plus
grace (1.5 s here, 15 s by default).
| Alarms left behind | Until all ran, at a tenth of the defaults | At the defaults (estimate: ×10) |
|---|---|---|
| 100 | 1.92 s | about 19 s |
| 1,000 | 3.08 s | about 31 s |
| 10,000 | 23.2 s (38.2 s before the fix below) | about 3.9 min |
Medians. The 10,000 row is from
bench/results/2026-11-05-9c495b4.csv
(commit 9c495b4, same machine, after the sweep fix; its samples ranged
from 23.19 to 23.23 s, and 100 and 1,000 measured the same as before);
38.2 s is the 4c10a33 run (37.2 to 38.2 s). The estimate scales the
whole time by ten, since the sweep interval and the lease timings set
nearly all of it (the work of a delivery is a few milliseconds and does
not scale); it was not measured at the defaults.
- Recovery is capped by the sweep, not by the work. Each node
delivers at most 256 alarms per sweep (
SWEEP_DELIVERinalarm_delivery.rs), split between the live nodes by a hash of the address. 1,000 alarms take two sweeps after the 1.5 s wait, as two nodes at 256 each predict. - Fixed: past three overdue periods, the survivors delivered the same
alarms. An entry overdue three times longer than a lease period plus
grace (4.5 s here, 45 s by default) is anyone’s, so that a node nobody
else sees can still be covered. The sweep used to walk first come, first
served: both survivors’ cursors sat on the same key and delivered the
same 256 a sweep, so 10,000 took 38.2 s (the model: 1.5 s wait, three
sharded sweeps at 512, then 33 sweeps of 256 for the remaining 8,464:
37.5 s), one node’s rate however many survive. Now each node delivers
its own shard first and keeps an eighth of the cap (32) for anyone’s
entries (
plan_sweep): 224 own a sweep each, 448 for two, plus whichever reserved picks the other node has not reached. The model: 1.5 s, three sweeps at 512, then 8,464 / 448 = 19 sweeps: 23.5 s, against 23.2 s measured. Two nodes at the full 256 each would take about 21 s; the reserve, which keeps anyone’s entries moving when a node’s own entries never leave the index, costs the difference. At the defaults that is about 45 alarms a second for two survivors, and recovery now grows with the node count.sim/tests/madsim_alarm_sweep.rschecks the rate and that every entry is still delivered when views disagree or a live node never delivers its shard. - No alarm ran twice in these runs; 14 alarm events were refused by
admission and retried, over all samples (25 in the
9c495b4run).
Not measured
A real bucket at this scale (each delivery restores an actor: listings and GETs with real round trips; the sweep lists the alarm index in pages of 1,000; a handful of alarms at the default timings, on R2, is measured), an SSD or slower disk, peak RSS, a cold page cache after a reboot, a node restarting in a cluster (it also waits for a heartbeat and writes each actor’s ownership record on first use), more than 100,000 actors on a node or 10,000 alarms at once, recovery at the default timings, and alarms whose handler fails or re-arms itself.
Workflow replay against recorded steps
Every event that moves a workflow forward replays its recorded steps from
the top (ADR 0019), so its cost
grows with them, against the event’s 50 ms CPU budget.
crates/wasmkeep-server/benches/workflow.rs times one run of an instance
with n recorded steps (each returning an integer), parked on a sleep:
the runner loads the steps, replays them, finds the park and sets the
alarm. It is reached through an actors.call from a driver actor, so
n = 0 is the floor (HTTP, the call, the runner’s own reads). From
bench/results/2026-11-06-30e68a8.csv
(commit 30e68a8, the AMD Ryzen 9 7950X workstation, a Rust guest, one
node, tmpfs).
| Recorded steps | One run (median) | Over the floor |
|---|---|---|
| 0 | 0.21 ms | |
| 10 | 0.24 ms | 0.03 ms |
| 100 | 0.37 ms | 0.16 ms |
| 1,000 | 1.72 ms | 1.51 ms |
| 5,000 | 7.74 ms | 7.53 ms |
How to read it
- About 1.5 µs a recorded step, steady from 100 to 5,000 steps. A workflow with thousands of steps replays in a few milliseconds of wall clock, well inside the 50 ms CPU budget (which counts the guest’s share of that time only).
- Recording is not measured here: the first run of each size recorded all its steps in one event, a durable write each.
- A Rust guest. TypeScript replays through JSON and an interpreter (QuickJS is 1.5× slower than StarlingMonkey on plain JavaScript); not measured yet.
- Over a workflow’s life replay is O(n²): 5,000 steps that each wake the workflow once replay about 12.5 million positions in all.
Three nodes against a real bucket
crates/wasmkeep-cli/tests/soak.rs,
run by bench/soak.sh: three wasmkeep serve nodes with the default lease
timings, eight clients making durable writes (exec-durable) to 60
actors through random nodes for 30 minutes, and every 3 minutes one node
killed with kill -9, kept down 75 s, then restarted (9 kills). Before
each kill, alarms are set on 8 of its actors, due at the moment it dies.
Each of its other actors then gets a client of its own, retrying a write
every 100 ms until one is acknowledged.
Two more clients write with a plain exec (answered before its upload)
to 20 actors of their own: the acknowledged increments lost are reported
(plain_lost: values handed out again, as later writes re-cover a lost
one, plus what the actors no longer hold at the end), not judged, since a
plain write may be lost with its node. For 30 s after each kill, four
readers read the killed node’s actors through the live nodes every
100 ms (takeover_reads_*: answers, failures, latency).
A run fails if an acknowledged write is lost (at the end, or in a read
during a takeover), a value is handed out twice,
an alarm never fires, or the 99th percentile of “writable again after the
kill” passes 30 s (WASMKEEP_SOAK_FAILOVER_P99_SECS; the worst is
reported, not judged; the runs below were judged by their worst).
WASMKEEP_SOAK_ACTORS changes the number of actors. Each run keeps
samples.csv, one row per write, probe, alarm, kill and restart, which
bench/soak_samples.py turns into a line per kill, and every node’s
metrics page at each kill.
Three runs of the same commit (0f2986f: per-actor leases, before
ADR 0020; the node-lease runs follow
below):
- R2 from a desktop: the three nodes as processes on the workstation above, over a residential connection, to an R2 bucket in Eastern North America.
- OVH from a desktop: the same, to OVHcloud Object Storage in Beauharnois (Standard, One Zone).
- OVH from three VPS: one node per OVH VPS-2 (4 vCPU, 8 GB, Debian 13)
in Beauharnois, next to the bucket, installed as in
Three nodes (systemd,
--app, mutual TLS between nodes); the clients run on the first VPS. The deployment to expect.
| R2, desktop | OVH, desktop | OVH, three VPS | |
|---|---|---|---|
| Acknowledged durable writes lost | 0 | 0 | 0 |
| Values handed out twice (two live owners would) | 0 | 0 | 0 |
| Alarms delivered | 72 of 72 | 72 of 72 | 72 of 72 |
| Writes acknowledged in 30 minutes | 21,260 | 129,577 | 171,050 |
| Durable write, as a client sees it (p50 / p99) | 587 ms / 2.0 s | 81 ms / 0.49 s | 54 ms / 0.42 s |
| Writable again after its node died (p50 / max) | 21.2 / 29.1 s | 16.6 / 25.9 s | 16.6 / 32.0 s |
| Alarm, after its node died (p50 / max) | 30.2 / 33.6 s | 21.2 / 33.8 s | 19.7 / 25.1 s |
| Restarted node ready | 3.0 s at most | 2.8 s at most | 7.2 s at most |
From …-0f2986f-soak-r2.csv,
…-0f2986f-soak-ovh-bhs.csv and
…-0f2986f-soak-ovh-bhs-3vps.csv
(the VPS run failed the soak’s 30 s check, see below).
How to read it
-
Nothing lost or duplicated in any run, over 27 kills under load. Both stores enforce the conditional writes leases and replication rely on. One difference from moto: R2 returns a version id with a write and none with a read, so the two tokens for one object differ. WasmKeep only hands them back to the bucket, never compares them; the test that did is fixed.
-
A durable write costs one conditional PUT, so its latency is the bucket’s: 54 ms next to the bucket, 81 ms from the desktop to the same store, 587 ms from the desktop to R2. The long tails (seconds) are writes that reached an actor while it was being taken over.
-
Failover is the lease, then a restore. Takeovers start about 12 s after the kill (10 s ttl + 5 s grace, from the last renewal). The new owner then downloads the actor’s history one object at a time: a snapshot and up to 64 segments, 82 ms each at the median from the VPS (237 ms at p90), so most restores take 1 to 6 s.
-
One actor in 267 missed the 30 s target on the VPS (32.0 s). Its restore took 19.8 s, while 21 other restores of similar size on the same node at the same time took 2 to 6 s: one GET hung. The bucket client keeps
object_store’s defaults (30 s per request, then a retry), and a restore waits for each object in turn, so one slow request held the whole restore. Since then a restore gives up a read after 5 s of silence and sends it again (ADR 0007, restore step 5). With it, the same soak on the VPS passed: writable again 15.5 s after the kill at the median, 24.6 s at worst, over 269 actors; durable write p50 49 ms, worst 8.1 s; nothing lost or duplicated, 72 of 72 alarms (…-c3d50ba-soak-ovh-bhs-3vps.csv). That run logged no read given up three times, and logged nothing for single retries then, so it does not show whether a stall was cut or none happened; every read given up is logged since. -
Twelve hours on the VPS (the same three nodes, a kill every 20 minutes, commit
a72fbac: per-actor leases, before ADR 0020): nothing lost or duplicated over 5,024,032 acknowledged writes, 35 kills and 1,129 actors taken over; 280 alarms fired, none missed. Durable write 47 ms at the median, 375 ms at p99, 18.8 s at worst (a write reaching an actor being taken over). Writable again after its node died: 17.0 s at the median, 31.6 s at worst, so the run failed the soak’s 30 s check once in 1,129. The nodes’ logs show why: after one kill, a surviving node took over 19 actors within 3 s (16 s after the kill, the lease and its grace from the last renewal), and restored them in parallel; one of them, 63 segments, took 17.5 s, while another of 63 on the same node at the same moment took 4.2 s. No read stalled long enough to be cut and sent again (5 s of silence), so it was slow reads, not a hung one; reads are not timed one by one. Alarms after their node died: 22.5 s at the median, 34.1 s at worst; a restarted node was ready within 8.6 s (…-a72fbac-soak-ovh-bhs-3vps-12h.csv). -
Twelve hours on the VPS, on node leases (the same nodes, bucket and cadence, the next night, commit
82dab61: ADR 0020, before the bounded segment uploads): nothing lost or duplicated over 3,659,687 acknowledged writes, 35 kills and 1,072 actors taken over; 279 alarms fired, none missed. Writable again after its node died: 18.2 s at the median, 24.4 s at p99 (the soak’s judged bound is 30 s), 89.6 s at worst, once: that actor’s restore (a listing and 55 reads, one after another) took 56 s, each read slow but never silent for 5 s, so none was cut; the 11 requests whose clients had given up meanwhile then ran, 16 s more. Nothing bounded a restore’s total time then; reads have since been made eight at a time, and a restore’s reads have one deadline (cluster), not measured here. Alarms after their node died: 24.0 s at the median, 35.9 s at worst; a restarted node was ready within 10.4 s. Durable write 57 ms at the median, 674 ms at p99, 29.4 s at worst. The writers wait for each answer, so the 27 % fewer writes than the night before are slower writes (a mean of 93 ms against 69 ms), most of it inside the owner’s durable write; the per-actor runs all ran one night and these the next, so the bucket’s speed and the design are not told apart here. Renewals are gone: PUTs other than segments fell from 3.1 to 7.4 per node and second to 0.80 (node lease writes, 0.33), 1.03 PUTs per durable write against 1.17 (…-82dab617-soak-ovh-bhs-3vps-12h-nodelease.csv). -
Both designs on the same night (the same three VPS and bucket): four 30-minute blocks, a kill every 3 minutes, alternating node leases (
82dab61) and per-actor leases (a72fbac, its own bucket prefix and data directories), the harness of82dab61for all four. Nothing lost or duplicated, every alarm fired, in all four.Block Leases Writes Durable p50 / p99 Writable again p50 / p99 / worst 1 per node 144,727 53 / 666 ms 17.4 / 23.6 / 34.3 s 2 per actor 185,012 50 / 419 ms 15.5 / 19.1 / 25.4 s 3 per node 126,974 62 / 747 ms 16.7 / 26.3 / 37.6 s 4 per actor 186,427 50 / 367 ms 15.6 / 21.4 / 26.2 s On one night, node leases made 27 % fewer writes (the writers wait for each answer, so that is slower writes, mostly in the tail: p99 about 1.7 times), and actors were writable again 1 to 2 s later at the median. So the 12-hour runs’ gap is mostly the design, not the night. Both node-lease blocks ran
82dab61, before the bounded uploads and restore fixes; where in the durable write the time goes is not yet measured (…-ab1-nodelease.csv,…-ab2-peractor.csv,…-ab3-nodelease.csv,…-ab4-peractor.csv; the file names carry the harness’s commit). -
Alarms after their node died: another node may deliver an alarm once it is overdue by the lease plus grace (15 s); then comes the next sweep (every 10 s), a restore and the deliveries in turn: 20 to 30 s at the median.
-
Lease renewals were most of the requests (per-actor leases, before ADR 0020, which removed them). Each actor in memory renewed its lease 3.3 s (a third of the ttl) after its previous renewal returned. In the R2 run, Cloudflare counted 47,083 PUT, 4,432 LIST and 47,762 GET for the bucket (
…-soak-r2-requests.csv): about 23,000 of the PUTs were renewals, more than the 21,260 writes. On R2 that is about $0.23 of Class A requests beyond the free tier per 30 minutes, and a cluster’s bill grew with how long actors stay in memory, not with what they do. OVHcloud does not bill requests. ADR 0020 replaced them with one lease per node; request counts since are not measured.
The allocator: system against mimalloc
The same suite on the same workstation, twice in a row: main as it is
(commit 6063067, glibc’s allocator) and with mimalloc as the server’s
global allocator (commit 59f01e8, an experiment that was not adopted).
From 2026-11-07-6063067.csv
and 2026-11-07-59f01e8.csv.
| Server CPU per request (8 clients) | System | mimalloc |
|---|---|---|
| Rust | 102 µs | 99 µs |
| Zig | 105 µs | 102 µs |
| C | 105 µs | 102 µs |
| TypeScript (QuickJS) | 228 µs | 224 µs |
| Python | 260 µs | 254 µs |
- No change worth a dependency. CPU per request moved 1 to 3%, and throughput 1 to 2%: within what one run of this suite varies on its own. Memory per resident actor moved under 2%, except TypeScript’s, which fell 4%; the server’s total resident memory grew 4 to 7% for Rust, Zig, C and TypeScript, and fell 2% for Python.
- Latency moves both ways between two runs of the same code. The Rust counter’s median write was 77.5 µs in the first run and 67.4 µs in the second, its median read 59.5 µs and 67.8 µs: a single run’s latency rows differ by up to about 15% without any change.
- What it means for packaging: a static
muslbuild would need a faster allocator than musl’s own to match glibc, and mimalloc brings nothing measurable on glibc; a glibc build linked against an older glibc stays the simpler way to one portable binary. Measured below (Portable builds): it is.
Portable builds
One commit (529fd96) built four ways, each run as the Rust counter’s
server by bench/compare, interleaved, three rounds; medians, from
2026-11-07-529fd96-portable-builds.csv.
Native is cargo build --release on the workstation (it needs glibc
2.39); glibc 2.28 and musl come from
scripts/build-portable.sh.
| Rust counter | Native | glibc 2.28 | musl | musl + mimalloc |
|---|---|---|---|---|
| Server CPU per request | 104 µs | 104 µs | 243 µs | 118 µs |
| Throughput (8 clients) | 61,300/s | 61,400/s | 27,400/s | 53,000/s |
| Startup to first response | 51 ms | 51 ms | 102 ms | 52 ms |
| Memory per resident actor | 249 KB | 250 KB | 265 KB | 264 KB |
- The glibc 2.28 build is as fast as the native one, on every row within the rounds’ spread, and runs on any x86_64 Linux with glibc 2.28 or newer (RHEL 8, Debian 10, Ubuntu 20.04 and later). It is the candidate for release binaries.
- A static musl build is slower: 2.3 times the CPU per request, under half the throughput, twice the startup. musl’s own allocator is most of it: with mimalloc the gap narrows to 14% more CPU per request and 13% less throughput, with 8% more resident memory, still behind glibc.
- The musl + mimalloc row is from a second set of rounds against native again (104 µs, 61,200/s there). The workstation had other work running (load average 7 to 14); the builds were interleaved, so it weighs on all of them alike.
WasmKeep on its own
| Median | |
|---|---|
| Instantiate a component (store + instance) | 2.4 µs |
| Instantiate the hello component (without calling its handler) | 19 µs |
| HTTP overhead over raw hyper (stateless hello) | 108 µs |
| Request to a resident actor (counter GET) | 233 µs |
| Wake a hibernated actor (reopen SQLite + instantiate) | 1.39 ms |
First request to a new actor (create database + on-init) | 3.5 ms |
| Write, single node | 243 µs |
| Write, replicated (in-memory bucket) | 0.87 ms |
Durable write, exec-durable (in-memory bucket) | 1.67 ms |
Failover: owner killed with kill -9, another node serves (3 runs, previous file, per-actor leases before ADR 0020) | 14.0, 14.2, 14.0 s |
A real bucket adds its PUT round trip to durable writes: 54 ms next to OVHcloud Object Storage, 587 ms from a desktop to R2 (measured). Failover is set by the node lease timings (15 to about 21 s after the node’s last heartbeat) plus a restore; see Running a cluster.
Reproduce
Both entry points require Bash. Full comparisons and bench/run.sh require
a soft open-file limit of at least 4096; compare --quick requires 1024
(or unlimited). They stop before building,
clearing previous Criterion output or starting a server if the limit is
too low. The floor is the smallest power of two at which the server keeps
every actor of the workload resident: the server sizes its resident-actor
cap from its limit, 256 descriptors reserved, the harness’s
--max-connections 64, and three per actor (256 + 64 + 3 * (actors + 2)).
Below it the benchmark would measure evictions. Full runs create 1,000
actors; quick runs create 100.
Check ulimit -Sn and ulimit -Hn, then explicitly
raise only the soft limit in a subshell if the hard limit allows it:
(ulimit -Sn 65536 && ./bench/run.sh)
The benchmark harnesses leave their inherited limits unchanged. On Unix,
wasmkeep serve separately attempts to raise its own soft limit within the
inherited hard limit, then sizes its connection and resident-actor caps from
the effective limit. This does not replace the harness preflight.
Choose TMPDIR deliberately:
RAM-backed storage and disk have very different creation and write costs.
Record the filesystem type with results, using relative paths or placeholders
for local directories.
bench/run.sh records the kernel’s transparent huge page setting
(/sys/kernel/mm/transparent_hugepage/enabled) in a last thp column,
unknown where there is none. Earlier files (up to 351ea57) lack it, so
check the machine by hand for those. Since commit 351ea57
the server turns them off for itself, so its numbers no longer depend on
it, but runs before that on a kernel set to always read about twice the
memory per actor (the workstation files cd48f41 to dec1a82). Do not
compare memory across that boundary.
./bench/run.sh # criterion suites + the comparison (if npm is installed)
WASMKEEP_TEST_S3_BUCKET=... ./bench/run.sh # also failover, with AWS_* pointing at an S3 API
WASMKEEP_COMPARE_BUCKET=s3://... ./bench/run.sh # also the comparison on a bucket (compare-bucket/ rows)
WASMKEEP_COMPARE_BUCKET=s3://... BUCKET_LABEL=minio-tmpfs ./bench/bucket.sh # that comparison alone, in a file of its own
WASMKEEP_COMPARE_BUCKET=s3://... CLUSTER_LABEL=minio-tmpfs ./bench/cluster.sh # three nodes of WasmKeep and celld, with takeovers
target/release/compare --quick wasmkeep-rust workerd # just the comparison, smaller
compare --bucket runs each WasmKeep target as a one-node cluster on the
bucket WASMKEEP_COMPARE_BUCKET names, each run under a prefix of its own,
so every write is also replicated: its rows are compare-bucket/.... Its
startup_to_first_response includes the 15 s a cluster node’s first start
waits before it listens (cluster). For a local bucket,
the MinIO image the Compose example pins
serves on loopback; give it a RAM-backed or a disk-backed data directory
deliberately, as with TMPDIR, and record which. wasmkeep-rust-durable
is the same counter with its POST durable before it returns
(exec-durable): fsynced on one node, in the bucket with --bucket.
The celld target (bucket runs only) is the workerd counter deployed to
one celld node on the same bucket
(celld-fleet and celld-bucket are its three-node targets);
bench/celld/fetch.sh downloads the pinned release and checks its build
provenance with gh attestation verify first.
Before publishing evidence, replace personal directory paths and generated
scratch paths with repository-relative paths, [repo] or [scratch]. Stage
only the intended artifacts, then run python3 -I scripts/check-benchmark-paths.py.
It checks the staged Git blobs under all of bench/ and docs/,
so redacting only the working copy cannot conceal an unsafe staged version.
Reviewed system paths, product routes and documentation-site links are allowed;
test files are scanned too. The check recognizes named-home paths and paths
wrapped in Markdown emphasis, validates IPv6 CIDRs, and limits regex/printf
escape exceptions to complete quoted patterns or reviewed printf examples. Adjacent private paths still
fail. An optional pre-commit hook
runs this check locally. CI checks files added or changed by every introduced
commit with --range BASE..HEAD, including paths removed before the final commit.
Unchanged files inherited from an old base are not attributed to the PR.
CI runs after publication, so it cannot prevent the initial push. This checks local paths; review logs
for other private information before staging them.
Regression check on pull requests
A pull request that changes the runtime, the server or the benchmarked
examples runs scripts/bench-ab.py in CI: the
base it merges into and the change are built and measured on the same
runner, three rounds of the instantiate, http and actors Criterion
suites with the two sides back to back. Each round gives a ratio per
benchmark; the check fails if a benchmark’s median ratio is more than 20%
slower. Three rounds at that threshold catch large regressions, not small
ones, and numbers from shared CI runners are not comparable with each
other or with the files in bench/results/: only the ratio between the two
sides of one run is, and the job’s summary shows it.
hello/request/raw-hyper (plain hyper, no WasmKeep code) never fails the
check; if it moved by more than 15%, the runner was too noisy and the run
only reports. A base that cannot be built or measured is reported, not
failed. To run it locally against any revision (it keeps its builds and
results under target/bench-ab/):
python3 -I scripts/bench-ab.py main
Security model
What WasmKeep protects, against whom, and how; what it does not do yet. The limits below are enforced by the code (each has a test), not aspirations.
Pre-alpha. Run code you trust, or code from people you trust. The Wasm sandbox protects the host from buggy actors; it is not yet a promise against a determined, hostile tenant (side channels such as Spectre are out of scope until a process- or VM-per-tenant tier exists).
Who can do what
| Party | Can | Cannot |
|---|---|---|
| Anyone who reaches the HTTP port | Send requests and open WebSockets to actors of open classes (creating actors by naming them), and to existing actors of existing classes | Reach internal classes; pass for another actor or node; create actors of existing classes; hold connections, bodies or sockets past the limits below |
| An actor (tenant code) | Use its own database, alarm, sockets, configuration; call actors of its tenant; make outbound HTTP to public addresses | Read files, open sockets, see the node’s environment, reach private networks or cloud metadata, touch another actor’s data, crash the host, run past its CPU, wall-clock, memory and SQL limits |
| A node of the cluster | Sign calls to other nodes; take leases; write the bucket | (Trusted: nodes and the bucket’s owner control the cluster) |
| Whoever can write the bucket | Everything: data, deploys, the cluster secret | (Trusted) |
Layers
Front door (crates/wasmkeep-server/src/http.rs)
- 30 s to send request headers, and idle limit between kept-alive requests.
- 10,000 connections per node (
--max-connections); more wait in the kernel backlog. Accept errors back off instead of spinning. - Request bodies: 16 MB (413 when declared larger; a failed read past it).
What a handler leaves unread is read and discarded, within the same
16 MB and for at most 30 s after the handler let go of the body
(not the body of a request sent with
Expect: 100-continuewhose handler read none of it: it is never asked for, see concepts). - WebSockets: 1 MiB per client message, 256 messages or 4 MiB queued per
client, 1,000 sockets per actor and 50,000 per node, idle clients pinged
then closed.
A socket is invisible to the actor’s other events until
on-websocket-openaccepts it. Close reasons never carry host errors. x-wasmkeep-*headers from clients are stripped or ignored: only the host sets who is calling.
Who may create an actor (ADR 0010): per class, open (default),
existing (outside requests reach only existing actors; nothing is created
for unknown names), existing:NAME,..., or internal (actors only).
The guest sandbox
- Capabilities: only what the
wasmkeep:actorworld imports. Filesystem and sockets interfaces that toolchains import anyway are inert (no directories, every socket and lookup refused). - CPU: 50 ms of guest time per event, and 2 s of SQL CPU per event (SQLite runs in the host, so it has its own budget, measured with the thread’s CPU clock and enforced inside a statement); wall clock 30 s per event (also for a stateless component’s request, response included).
- Memory: 128 MiB per linear memory, 2 memories and 32 core instances per component; resident actors capped at 90% of the instance pool, the least recently active idle one hibernating to make room.
- Outbound HTTP: public addresses only (the host resolves, checks and
connects to the checked address; TLS verified against the name);
--allow-outboundand--outbound-caopen specific internal services. The ports browsers block are refused too (the Fetch standard’s list: SMTP 25, 465 and 587, FTP, SSH, IRC and other non-HTTP services), since a crafted HTTP request can drive some of those servers;--allow-outbound-portopens one. 16 requests in flight per actor, 256 per node. - What a guest prints reaches the node’s log within a line rate per
instance and per node (
--guest-log-rate; past it, lines are dropped and counted), cut at 4 KB a line, with control characters escaped: a guest cannot flood the log, forge entries of its own or send terminal escapes. What it prints is its choice, secrets included, so the node’s log is as sensitive as the actors make it.
SQLite sandbox (crates/wasmkeep-storage/src/db.rs)
- No
ATTACH,VACUUM, extensions,fts3_tokenizer, or temporary tables, views, triggers or indexes (ordinary ones are allowed, but not on host tables); virtual tables limited to fts3/fts4/fts5/rtree; pragmas from an allow-list; defensive mode;trusted_schema=OFF. - Host tables (
_wasmkeep_*) invisible to guests. - 1 GB per database; 16 MB per value and per query result; a statement stopped at the event’s wall clock.
- 256 MB of write-ahead log between two captures: past it, statements that write fail until the event ends (reads, commit and rollback still run). Capture reads the log frame by frame and holds only the newest version of each changed page, so rewriting the same rows costs no memory (a transaction touching many distinct pages still holds them all).
Between nodes (crates/wasmkeep-server/src/cluster.rs)
- Calls carry an HMAC-SHA256 signature (secret from the bucket, never sent) over the target node, method, path, the body’s SHA-256, caller, chain, time and a nonce unique to each send; valid for a minute, accepted once (the replay record expires in order and holds up to 500,000 signatures, several thousand calls a second into one node). The receiver checks the body against it before the actor runs (only a validly signed call is buffered). Heartbeats count only under their own key.
- Without a cluster listener, only with
--cluster-plaintext accept(by defaultserverefuses to start a cluster node without one: nodes talk over mutual TLS by default): plain HTTP, not encrypted, and responses are not signed: keep node-to-node traffic on a private network. A node without one warns when it advertises a public address. - With one (
--cluster-listen,--cluster-advertise; ADR 0011): mutual TLS 1.3 to and from peers that also have one, each side’s certificate pinned in its live heartbeat; no session resumption; the client’s pin is checked again on every request. The port serves actor paths and the health and readiness checks only, and never forwards a request again. - Revocation:
wasmkeep cluster fence <node>(ADR 0020) writes a fence over the node’s heartbeat (its node lease): the running process stops at its next heartbeat write, no peer trusts it from its next listing, and the actors it held are free 15 s after its last write. A fence revokes that process, not the machine: a service manager that restarts it starts a new process, which passes its start check once the fence is 15 s old and is trusted again. To revoke a machine, also stop its service and disable the restart, and rotate what it holds: the bucket credentials, and the cluster secret (cluster/secret) if it may have been read. Deleting the heartbeat does not revoke cleanly: the node stops too, but its actors stay held until a fence or a new process with its id. With no heartbeat object, the fencecluster fencecreates is dated by the bucket’s own time for that write, never earlier than the operator’s clock (a clock running behind would free the actors too early). A peer stops being trusted once its heartbeat is fenced or released, deleted, names a new key, or expires (its ttl, three heartbeat periods, plus the allowed clock skew); a node that cannot list the heartbeats trusts each pin only until its heartbeat’s expiry. From then on its handshakes are refused, its kept-alive requests get a 503, nothing is sent to it (pooled connections included), relayed WebSockets are cut on either side within 500 ms, and answers from it or to it (forwarded responses,actors.call) are not delivered, even if already streaming. A request it sent before that still runs to the end: only its answer is withheld. --cluster-plaintext refuse(the default; phase B of ADR 0011) closes the plaintext path: a node’s call counts only on the cluster port. A signed call on the public port is treated as an outside client’s (access rules apply, no caller, no chain,internalclasses refused), and a valid signature there is spent, so a plaintext copy can never be replayed onto the cluster port; peers without a cluster listener are not used (their actors get a 503 from this node), and TLS peers are never reached in plaintext. The forwarded header gives no privilege on any port: it only stops a request being forwarded again.- One app per tenant per bucket prefix, until each app owns its state
(planned for 0.4.0, ADR 0021). Actors are
keyed by tenant, class and name, so a second app of the tenant on the
same prefix would share the first one’s actors. The first
wasmkeep deployof an app refuses when another app of its tenant is deployed on the prefix, and a node refuses to start while a node of another app of its tenant is live there. Not caught: two apps of one tenant deployed on one prefix before this check (they keep sharing actors;deployandserve --appwarn), two first deploys at the same moment, and a deploy by a CLI older than 0.3.0. Deploy the second app on another prefix, or under another tenant if another owner runs it (deploying). - One bucket prefix is one trust domain: every node on it signs calls with the same cluster secret, whatever app or tenant it serves.
The bucket (crates/wasmkeep-server/src/replica.rs)
- Restore streams one object and one page at a time; chains that would build a database over twice the node’s cap, objects larger than such a database, and files without checksums are refused; every file’s chain checksums are verified before the result is put in place.
- Every read of a restore is given up after 5 s of silence and sent again, up to three times; then the restore fails and the next event restores from a fresh listing.
- A node proves at start, before it writes a heartbeat or takes a lease,
that the bucket enforces conditional writes: on a throwaway object under
probe/, a create over it and a replace with a stale version must be refused. A bucket that ignores them would let two nodes own one actor, so it is refused: the node stays not ready andserveexits (wasmkeep_cluster::bucket_check).
Configuration and secrets
--env CLASS=FILE (NAME=value files, readable only by the server’s user).
Values never reach the data directory, the bucket or the logs; parse errors
name the file and line, never the value.
wasmkeep deploy --config CLASS=FILE stores plain configuration in the
bucket, in the clear, as part of the version
(ADR 0013): only for what
anyone who can read the bucket may see (whoever can read it can already
read every actor’s data). Comments are not uploaded; names that look like
secrets are warned about without the value; the manifest holds digests,
not values; Debug output of configuration shows names only. Node --env
values win by name.
wasmkeep secrets stores an app’s secrets in the bucket, sealed
(ADR 0016): ChaCha20-Poly1305 under a
key derived per app from a 32-byte secrets key, which the operator gives
to the nodes (--secrets-key, refused if its group or others can read
it) and to the machine running wasmkeep secrets. Each object opens only at its own bucket
prefix, tenant, app and sequence number. Values come from stdin or a file,
never from the command line, and are never printed or logged.
- Protects against anyone who can read the bucket, or its backups: they see neither names nor values, and cannot forge or move a sealed object.
- Does not protect against whoever can write the bucket: they can
deploy code that reads
env.getand sends the values out. Signed deploys (planned, not yet designed) would narrow that. - A node that cannot open the current secrets (no key, another key, an altered object at once; 30 s without reaching the current set) refuses the app’s events with 503, hands its actors to a node that can, takes none, and reports not ready. Past those windows it never runs an actor with an older or empty set; when no node can open them, the app’s events are all 503.
cluster/secret(node-call signing) is still stored in the clear; a bucket reader can sign node calls on a node started with--cluster-plaintext accept. Withrefuse(the default) node calls are honoured only on the mutual-TLS cluster port, whose peers are pinned in their heartbeats.
Access rules travel with the deployed version too: a node following an app
applies the version’s rules, decided with the class each event takes, so
no event runs new code under an old rule or the reverse. --access is
refused with --app once rules are deployed. A rollback that reopens a
class needs --allow-reopen.
Known gaps
- Hostile multi-tenancy: every actor of a node shares one process (see the note above).
- Node-to-node traffic goes over mutual TLS by default
(ADR 0011): a node in a cluster needs
--cluster-listenand--cluster-advertise, and refuses plain HTTP between nodes. A node started with--cluster-plaintext acceptopens the plaintext path again: without a listener it reaches peers at their plain address, and with one it stays reachable, signed calls included, on its public port; there a response to a call is not signed, and someone on the path can read traffic and change responses. Refusing nodes still serve outside clients on the public port as usual, and the mode is not published: nothing stops a node from being started inacceptmode later, so each node’s flags are what count. - Host work other than SQL (encoding sends and HTTP, WebSocket frames) is not charged to any CPU budget; it is small per call, and the wall clock bounds it.
- In a cluster, a deleted actor of an
existingclass can be woken from outside (as an empty actor). - Limits are per node. A process serves one tenant (several tenants per process is planned but not started), so today that is also per tenant; within a tenant, one class or actor can use the node’s whole share (instance pool, connections, sockets), bounded per actor only where noted above.
Audits
Before the first public release the code had an internal security review in ten parts (front door, guest boundary, cluster); each fix came with a test that fails without it. Later hardening: plain HTTP between nodes refused by default, outbound HTTP refusing the ports browsers block, and limits on what actors print reaching the node’s log (all described above). No independent third-party audit has been done yet.
Reporting a vulnerability
Report privately through the repository’s security advisories, not in a public issue.
Licensing and compliance
What WasmKeep’s license obligations are, what it depends on, and what users
inherit when they build actors. The dependency list is kept current by
scripts/licenses.sh.
WasmKeep itself
- License: Apache-2.0 for all code written for this repository (LICENSE), with its explicit patent grant from every contributor. See NOTICE.
- Name: the WasmKeep name (and any logo, once there is one) is reserved (TRADEMARK.md).
- Contributions: under the Contributor License Agreement (CLA.md). The CLA also lets the maintainer offer commercial licenses (ADR 0004); contributions stay available under Apache-2.0.
- Authorship: written by Julien Turbide with AI models (mostly Claude in
Claude Code, with Codex and Kimi for parts and reviews) under his
direction and review. The implementation is original; what comes from
other projects is used as published, under its own licence: the WASI
interfaces vendored in
crates/wasmkeep-wit/wit/deps/(and copied tosdks/typescript/wit/deps/,crates/wasmkeep-sdk/wit/deps/,sdks/go/wit/deps/andsdks/python/wasmkeep/wit/deps/), published by the WebAssembly Community Group under the W3C Community Contributor License Agreement, bindings generated from WIT files, and the dependencies listed byscripts/licenses.sh(see the README’s Authors and History and use of AI). Where WasmKeep interoperates with another project’s format or protocol, it is an independent implementation of the published format:- LTX (the replication file format) matches
github.com/superfly/ltx(Apache-2.0). The reference library is used only by the conformance tool inconformance/ltx(a development tool, never shipped), whose output files are the test fixtures incrates/wasmkeep-storage/tests/ltx. - WIT, WASI, the component model: open standards of the W3C WebAssembly Community Group and the Bytecode Alliance.
- LTX (the replication file format) matches
Dependencies
All Rust dependencies (Cargo.lock, about 400 crates) are under permissive
licenses: MIT, MIT-0, Apache-2.0 (some WITH LLVM-exception), BSD-2/3-Clause,
0BSD, ISC, Zlib, Unicode-3.0, Unlicense, BSL-1.0, CC0-1.0 and
CDLA-Permissive-2.0 (the Mozilla CA list in webpki-roots). None is
copyleft. SQLite (bundled by rusqlite) is in the public domain.
scripts/licenses.sh lists every crate with its license and fails on one
outside that set. cargo deny check (deny.toml) checks the
same set, refuses crates from other registries or git, and fails on known
vulnerabilities (RustSec); its one accepted advisory is bincode being
unmaintained, reached only by the simulation tool. Run both before a
release. The list is an inventory: it does not contain the notices
themselves. A binary distribution must also carry each dependency’s
copyright lines, NOTICE contents and license texts (Apache-2.0 and MIT
require the notices to travel with the binary; CDLA-Permissive-2.0 requires
its text). scripts/notices.sh gathers them with cargo about
(about.toml): every crate in the binary for the release
targets, grouped by license with each license’s full text, the
CDLA-Permissive-2.0 text included, and the NOTICE files dependencies ship.
The release workflow puts that file in every tarball, next to the binary.
The example components a release carries (counter.wasm and the others)
link the Rust SDK’s dependencies: scripts/notices.sh --components writes
their notices the same way, for wasm32-wasip2, and the release attaches
that file, THIRD_PARTY_NOTICES-components.txt, next to them.
scripts/sbom.sh writes a CycloneDX SBOM of the wasmkeep binary for one
target.
Development-only tools, never part of WasmKeep: workerd (Apache-2.0, in
bench/workerd), the Go LTX conformance tool, wasm-tools and wit-bindgen
(Apache-2.0 WITH LLVM-exception), Zig (MIT), TinyGo (BSD-3-Clause),
componentize-py (Apache-2.0), mdBook (MPL-2.0, builds the docs site only).
What users inherit when they build actors
A component bundles its language’s runtime. Distributing a component (not running it on your own servers) carries that runtime’s license:
| Language | Runtime inside the component | License |
|---|---|---|
| Rust | the Rust standard library | MIT OR Apache-2.0 |
| TypeScript (default) | QuickJS-ng, via componentize-qjs | MIT (QuickJS-ng); Apache-2.0 (componentize-qjs) |
TypeScript (--engine starlingmonkey) | SpiderMonkey, via StarlingMonkey and ComponentizeJS | MPL-2.0 (SpiderMonkey): distributing the component requires making SpiderMonkey’s source available; Apache-2.0 (StarlingMonkey, ComponentizeJS) |
| Python | CPython, via componentize-py | PSF License (keep its notice); Apache-2.0 |
| C, Zig | wasi-libc | MIT / Apache-2.0 / CC0 |
| Go | the TinyGo runtime | BSD-3-Clause |
| All (except Rust and Go) | the WASI 0.1 adapter (Wasmtime) | Apache-2.0 WITH LLVM-exception |
Running components on your own servers (the normal use of WasmKeep) is not distribution.
Patents
Apache-2.0 gives users a patent license from every contributor for their contributions. It says nothing about third parties’ patents. WasmKeep’s design draws on long-published, widely implemented ideas (the actor model, 1973; virtual actors as in Microsoft Orleans, 2014; single-writer leases on conditional writes; WAL shipping as in Litestream and LiteFS).
Trademarks of others
WasmKeep’s docs compare it with, or name, other products (Cloudflare Workers and Durable Objects, workerd, celld (Deno), wasmCloud, Spin, Rivet, Microsoft Orleans, Litestream, LiteFS, Fly.io, AWS S3, Cloudflare R2, OVHcloud Object Storage, MinIO, Ceph, Hetzner, DigitalOcean, Scaleway, Kubernetes, Firecracker). The names are used only to identify those products; they belong to their owners, and WasmKeep is not affiliated with or endorsed by any of them.
Privacy
WasmKeep collects nothing by default. The usage ping is opt-in
(--usage-ping), sends the seven fields documented in
telemetry.md, and has no built-in destination. Actor data
stays on the operator’s machines and bucket.
Cryptography
TLS (rustls with ring) for actors’ outbound HTTPS and for mutual TLS 1.3
between nodes (certificates made in memory with rcgen), HMAC-SHA256
signatures on node calls, and ChaCha20-Poly1305 with HKDF-SHA256 (ring)
for secrets sealed in the bucket. The bucket client (object_store) and
the usage ping use rustls with AWS-LC (aws-lc-rs) for their HTTPS, and
object_store also uses AWS-LC for S3 request signing. All are standard
algorithms from published open-source libraries. Check the export rules of
your jurisdiction before distributing binaries.
Usage ping
WasmKeep can send an anonymous usage report, so the project learns how many
installations exist and what they look like. It is off by default: a
node sends nothing unless it is started with --usage-ping.
wasmkeep serve --usage-ping --actor counter=counter.wasm
What is sent
One small JSON document, a minute after the node starts and then once a day, as an HTTP POST:
{"schema":1,"version":"0.3.0","os":"linux","arch":"x86_64","cluster":true,"classes":"1-9","resident_actors":"10-99"}
| Field | Meaning |
|---|---|
schema | Format version of this report |
version | WasmKeep’s version |
os, arch | Operating system and CPU architecture (linux, x86_64, …) |
cluster | Whether the node runs in a cluster (--node-id) |
classes | How many actor classes it serves, as a bucket: 0, 1-9, 10-99, 100-999, 1000-9999, 10000+ |
resident_actors | How many actors are in memory at that moment, same buckets |
That is all. No identifier for the node or the installation, no host names, addresses, tenant, class or actor names, no request data, no configuration. The receiving end sees the connection’s IP address, as any HTTP server does; how the collector treats it will be stated here when one exists.
Each report is written to the node’s log (sending the usage ping)
before it goes out, so you can see exactly what was sent. Failing to send
is logged at debug level and never affects serving.
Where it goes
Nowhere yet: the project does not run a collector, so in current builds
--usage-ping logs that it has no endpoint and sends nothing. When a
collector exists, its address will be built into the release and listed
here.
WASMKEEP_USAGE_PING_URL sends the report to another URL instead, for
example your own collector.
The code is short: crates/wasmkeep-cli/src/usage.rs.
Dogfood log
What building real applications on WasmKeep taught us: every friction point, with what we did about it. Newest first within each app.
Watch (uptime monitor), examples/watch
One monitor actor per URL, checking on its alarm; one hub actor that
creates monitors, records alerts durably and delivers them to a webhook with
retries, and serves a live dashboard. About 600 lines of Rust in two
components.
What worked well
- The actor model fits. Each monitor is a few dozen lines of state and one alarm. Scheduling, persistence and isolation came for free; nothing needed a lock, a queue or a cron daemon.
- Alarms as the schedule. “Check, record, set the next alarm” is the whole loop, and it survives hibernation, restarts and (since ADR 0009) the death of the node.
exec-durablewhere it matters. The hub records alerts durably, so an acknowledged state change survives a crash, while ordinary bookkeeping stays fast.- Calls with a trusted caller header. The hub accepts alerts only from
monitors (
x-wasmkeep-caller), with no shared secret to manage. - WebSockets from the actor. The dashboard streams each monitor’s checks live with five lines of guest code.
Friction, and what we did
| # | Friction | Status |
|---|---|---|
| 1 | No outbound HTTP helper. Every app re-implemented the wasi:http client plumbing (about 70 lines). | Fixed: wasmkeep_sdk::http::fetch (method, URL, headers, body, one timeout, body limit), sending a known Content-Length. |
| 2 | Actors can reach any address over outbound HTTP, including localhost, private networks and cloud metadata (169.254.169.254): server-side request forgery for any tenant’s code, and a way to reach other nodes’ internal ports. A monitor that checks arbitrary URLs makes this obvious. (The restriction had been planned but not yet built.) | Fixed: the host resolves names itself, refuses every non-public address (IPv4 and IPv6 special ranges, IPv4 inside IPv6), connects to the checked address (no DNS rebinding), and verifies TLS against the host name. --allow-outbound CIDR opens specific ranges. It also refuses the ports browsers block (mail on 25, 465 and 587, FTP, SSH, IRC and so on, 83 in all) whatever the address; --allow-outbound-port PORT opens one. |
| 3 | An actor does not know its own name or class. Requests arrive with /actor/<class>/<name> removed and alarms carry nothing. The hub hard-codes its own path for the dashboard; each monitor is told its id in its configuration. | Fixed: actors.self-address in the WIT, in all five SDKs. The hub builds its links from it, monitors take their id from it, and the hub takes an alert’s id from the caller header, so a monitor can only report on itself. |
| 4 | No configuration or secrets. The webhook URL is stored through a request; an API key would have to be too, or be compiled in. | Fixed: the env interface, in all five SDKs. Values come from files on the node (--env CLASS=FILE), per class, never in the component or the bucket. The hub takes ADMIN_TOKEN and WEBHOOK_URL from it. |
| 5 | No way to list actors. By design (actors are islands), so the hub keeps a registry; but GET /monitors makes one call per monitor, in sequence: fine for tens, slow for thousands. | Pattern documented here; batch or parallel calls are a possible SDK addition. |
| 6 | No authentication story. Anyone who can reach the hub can add monitors or change the webhook. Watch v1 leaves this to a reverse proxy. | Partly fixed: the pattern is documented (deploy guide), and the hub follows it: with ADMIN_TOKEN set, changes and the webhook URL need Authorization: Bearer, compared without stopping at the first differing byte. Helpers in the SDKs would save each app those 20 lines. |
| 7 | Testing an app means writing an HTTP client and a fake server. The end-to-end test (crates/wasmkeep-server/tests/watch.rs) needed both, and a bug in the fake server (it ignored chunked bodies) looked like a delivery bug at first. | Open: a test harness crate for apps. |
| 8 | Authorization on the callee is easy to forget. The monitor accepted PUT /config and DELETE from anyone, so the hub’s checks could be skipped by calling /actor/monitor/<id>/config directly: point a monitor at any URL, or delete it. Found while adding the hub’s token. | Fixed: monitors take changes only from a hub caller (x-wasmkeep-caller). A class-level rule (“only reachable from these classes”) in the host would make this a declaration instead of code in every actor. |
| 9 | Any request creates an actor. GET /actor/monitor/<anything> creates a database for that name. Fine for rooms and counters; for Watch, anyone can fill the disk with empty monitors. | Fixed: --access CLASS=existing (ADR 0010): an unknown name answers 404 and leaves nothing on disk or in the bucket; internal refuses outside requests. Watch serves monitors as existing. The hub runs as existing:main. |
| 10 | “Up” is what the site answers the checker, not a browser. A check is ok for any status below 400, and a site can answer a client it does not know with an error (a 404, say) although it is up: the monitor reports it down. | Partly there: a check names itself, User-Agent: wasmkeep-watch/<version>, which a test now reads on the wire, so a site’s operator can see it and allow it. Open: a monitor cannot set its own request headers or the statuses it accepts. |
Alert delivery semantics
The hub durably records each monitor’s state change before sending a webhook. Repeated reports of its last recorded state are skipped, even if an alarm retry uses a new timestamp. A later up/down transition is a new alert, and each monitor is deduplicated separately. Existing alert history supplies the last state, so existing databases need no migration.
Webhook delivery remains at least once: a crash after the receiver accepts a webhook but before the hub durably records delivery can send it again. A lost HTTP response has the same ambiguity. This is not an exactly-once guarantee; a receiver requiring one needs its own idempotency handling. Failed deliveries remain pending and retry normally.
Releasing
For maintainers. A release is a signed tag on a commit of main that CI
passed on; two workflows do the rest, and a person approves each step that
cannot be undone.
What a release is
| Part | Where | Made by |
|---|---|---|
| Linux binaries (x86_64, aarch64), SBOMs, checksums, Sigstore bundles | the GitHub release | .github/workflows/release.yml, on the tag |
| The image, linux/amd64 and linux/arm64, signed | ghcr.io/wasmkeep/wasmkeep:<version> | release.yml |
| The Rust SDK | crates.io, wasmkeep-sdk | .github/workflows/publish.yml, when the release is published |
| The TypeScript SDK | npm, @wasmkeep/sdk | publish.yml |
| The Python SDK | PyPI, wasmkeep | publish.yml |
| The Go SDK | this repository, tag sdks/go/v<version> | a maintainer, with the release’s tag |
One version for all of it: the workspace version in Cargo.toml, which
every SDK manifest carries (scripts/check-versions.py).
Steps
The SDKs are published with every release from 0.2.0 on; 0.1.0 is on no registry.
- Prepare. In one pull request: the version in
Cargo.tomland every manifest,publish = trueincrates/wasmkeep-sdk/Cargo.toml(it stays so from then on), the release’s section inCHANGELOG.md(it becomes the release text), and install commands in the docs at the new version. Merge it, and wait for CI to pass on that commit ofmain. - Tag. Two signed tags on that commit:
v<version>, andsdks/go/v<version>for the Go module.release.ymlwaits for CI on the commit, builds and signs everything, pushes the image and opens a draft release. A tag on a commit CI did not pass on fails here: run CI again on the commit, then the workflow again. Never move or delete a tag: the module proxy and the registries keep what they saw. - Publish the draft. Read it, then publish it. That starts
publish.yml. - Approve the registries.
publish.ymlfirst checks the release (the tag is the version of every manifest, CI passed on its commit, theregistriesenvironment is protected), builds every package from that commit and asks each registry what it already has. Only then does each registry that lacks the version wait for a reviewer. Before approving, look at the run: it ispublish.yml, started by the release, on the tag. Approving publishes: a version on a registry stays there for good. - Check from outside. In an empty directory:
cargo add wasmkeep-sdk,npm install @wasmkeep/sdk,pip install wasmkeepandgo get github.com/wasmkeep/wasmkeep/sdks/go@v<version>each give the new version, and each registry’s page names this repository as the source.
If a run stops half-way
- A registry refused, or an approval was rejected or ran out: use “Re-run failed jobs” on the run, or run the workflow by hand on the tag (Run workflow, with the tag as the ref). It publishes the same files.
publish.ymlitself is wrong at the tag: it cannot be fixed for that tag, since a run always uses the tag’s own copy. Publish what is missing by hand from a checkout of the tag (cargo publish,npm publish,twine upload), and fix the workflow for the next release.
Either way a registry that has the version starts no job and asks for no approval, and PyPI is asked file by file, so a release that got its wheel but not its source archive gets the missing one. Nothing is retried in a loop: a registry that refused keeps its error in the job’s log.
Set up once
The registries trust this repository’s publish.yml by its file name and
its environment, with no stored token (“trusted publishing”). They do not
check the branch or the tag: a copy of the file on any branch has the same
identity. The registries environment is therefore the boundary, and its
settings matter.
- GitHub, the
registriesenvironment: required reviewers; deployment limited to the tagsv*; administrators not allowed to bypass it; “prevent self-review” once there is more than one maintainer.publish.ymlrefuses to run if the environment is missing, has no reviewers or accepts any ref. - GitHub, the tags: the ruleset that keeps
v*andsdks/go/v*from being moved or deleted. With several maintainers, also restrict who may create them. - The names. A registry can trust a workflow only for a package that
exists, except PyPI. Reserve the crate and the npm package once, by
hand, with a placeholder version
0.0.0that says so in its description:cargo publishof a crate namedwasmkeep-sdk, andnpm publishof@wasmkeep/sdk. Reserve the unscoped npm namewasmkeep-tsthe same way, as a placeholder that points to@wasmkeep/sdk: it is the name of the SDK’s build command, andnpxlooks a command up as a package when it is not installed. Every real version then goes out throughpublish.yml, with a provenance statement. - crates.io: in the crate’s settings, a trusted publisher: owner
WasmKeep, repositorywasmkeep, workflowpublish.yml, environmentregistries. - npm: in the package’s settings, a trusted publisher with the same
values, with
npm publishallowed (a new one allows only staged publishes otherwise). npm removes a trusted publisher that has not published within two days of being created: create it during step 1 of the first release, not earlier. The fields are case-sensitive, andrepository.urlinpackage.jsonmust be this repository. - PyPI: a pending publisher for the project
wasmkeepwith the same four values. The first run creates the project. A pending publisher does not reserve the name: make the first release soon after.
publish.yml keeps its file name and its jobs stay in it: a renamed or
reusable workflow would be refused by the registries.
0001. Target WASI 0.2 and build Rust guests with plain cargo
- Status: accepted
- Date: 2026-11-02
- Milestone: M0
Context
Which WASI version to target was an open question at the start: wasi:http
0.2 is stable, 0.3 brings native async. Wasmtime 50 (pinned for M0) ships host
implementations of both (wasmtime-wasi-http features p2 and p3).
The initial design named cargo-component as the Rust guest toolchain. Since
Rust gained the tier-2 wasm32-wasip2 target, a plain cargo build --target wasm32-wasip2 of a cdylib already emits a component, and the official
wasip2 crate provides the wasi:http/proxy bindings and export! macro.
Decision
- Guests target WASI 0.2 (
wasi:http/proxy@0.2). The host linkswasmtime_wasi::p2andwasmtime_wasi_http::p2only; thep3features are off. - Rust guests are built with plain cargo for
wasm32-wasip2, using thewasip2crate.cargo-componentis not required. The target is listed inrust-toolchain.toml, so rustup installs it automatically. - WASI 0.3 is revisited when the guest toolchains (wit-bindgen async, TinyGo,
componentize-py, ComponentizeJS) treat it as stable, and at the latest when
M1’s
wasmkeep:actorworld needs async exports. Because tenants program against WasmKeep’s own WIT world, the migration is confined to the host bindings and the SDK.
Consequences
- Every guest toolchain WasmKeep plans to support (Rust, TinyGo, componentize-py, ComponentizeJS) can produce 0.2 components today; 0.3 would exclude some of them.
- Request handling uses the synchronous-export, async-host model: one event at a time per instance, which is also the actor model’s contract.
- One fewer tool to install for contributors and in CI. The
wasmkeep-sdkcrate (M1) will usewit-bindgendirectly for thewasmkeep:actorworld. - A later move to 0.3 is a breaking change for guests and needs its own ADR and migration note.
0002. The wasmkeep:actor WIT world, v0.1.0
- Status: accepted; extended to v0.2 by ADR 0003 (0.x may still break).
Deferred items since:
envadded 2026-11-04 (deploys and secrets in ADRs 0013 and 0016); guest stdout and stderr reach the node’s log since 2026-11-06, not in M1 as planned (until then the host discarded them). - Date: 2026-11-02
- Milestone: M1
- Source:
crates/wasmkeep-wit/wit/actor.wit
Context
The initial design sketched the world (storage, alarms, actors.call, exports
on-init, on-alarm, on-websocket-message, and the wasi:http handler)
but left several types undefined (value, row, request, response, the
error types) and imported wasi:logging, which Wasmtime does not provide.
This ADR records how v0.1.0 fills those gaps. The interface is the product, so
every choice below favours “small now, additive later”.
Decisions
Shape: two worlds
actor-core: WasmKeep’s own imports (storage,alarms,actors) and exports (on-init,on-alarm). Host bindings (wasmkeep-wit, wasmtimebindgen!) and guest bindings (wasmkeep-sdk, wit-bindgen) are generated from this world only.actor:actor-core+wasi:http/proxy@0.2.12+ thewasi:cliimports that Rust’s standard library always pulls in (environment, exit, terminal-*), served empty by the host. This is the world components are validated against (wasm-tools component targets, in CI and later inwasmkeep deploy), so it doubles as the capability list: filesystem and sockets are not in it.
Why: generating bindings for actor would duplicate the WASI types already
provided by wasmtime-wasi-http (host) and the wasip2 crate (guest). With the
split, each side reuses the standard WASI bindings and adds only WasmKeep’s.
Storage
valueis a variant over SQLite’s storage classes:null,integer(s64),real(f64),text(string),blob(list<u8>).queryreturnsrows { columns, values }: column names once, then rows as lists of values. Smaller on the wire than a list of records with names.- One statement per call (
multiple-statementserror otherwise): no accidental batches, no stacked-query injection. - Autocommit per statement; explicit
BEGIN…COMMITacross calls inside one event is allowed, and a transaction left open when the event ends is rolled back. (No implicit per-event transaction: it would hold the write lock across outbound calls and surprise anyone who catches an error.) sql-erroris a record of anenumkind plus a message, so guests can match on the kind (constraint,quota-exceeded, …) without parsing.exec-durableis in v0.1 even though replication arrives in M2, so guest code written now does not change then. On a node without a bucket it means “committed and fsynced locally”. This settles the open question (per-call opt-in vs always durable) as per-call opt-in.
Alarms
set-alarm(at), clear-alarm() as first sketched, plus get-alarm()
(Durable Objects users expect it; a rate limiter needs it to avoid
re-scheduling). At-least-once delivery is stated in the interface docs.
Actor-to-actor calls
call(address, request) -> result<response, call-error> with fully buffered
request / response records (method, path, headers, body). The callee
receives it through its normal wasi:http/incoming-handler, so there is no
second export to implement and every language can handle it. address is
class/name inside the caller’s tenant; cross-tenant calls are impossible by
construction. call-error includes reentrant: with strict one-event-at-a-time
execution, calling yourself or any actor already waiting in the call chain
would deadlock, so the host refuses it up front.
Lifecycle exports return results
on-init and on-alarm return result<_, string>. A failing migration must
fail the event instead of trapping; a failing alarm is retried.
Deferred, with reasons
- WebSockets (
on-websocket-message, accept/send/close): without a send API an exported message handler is dead weight, and adding an export later is a breaking change anyway. They arrive together in a later minor version (ADR 0003). wasi:logging: Wasmtime has no host implementation. Guests write to stderr; the host will capture and tag it per actor (M1). Revisit if a standard logging interface lands in WASI.env(config and secrets): no example needs it yet.- Actor identity (
own address): the request path carries the name; add an import when an example needs it.
Consequences
- Guests implement two traits in Rust:
wasmkeep_sdk::Actor(hooks, default no-ops) andwasmkeep_sdk::http::Guest, and callwasmkeep_sdk::export!. - Every example is checked in CI against the
actorworld, so the WIT, the SDK and the examples cannot drift apart. - The host must link only what the
actorworld lists. The server links all of WASI 0.2; restricting the linker to the world was planned for M1 and is still open. - Any change to
actor.witneeds a superseding ADR once 0.1.0 is published, and an RFC issue once the project takes outside proposals (see CONTRIBUTING.md).
0003. WebSockets in the actor world (wasmkeep:actor@0.2.0)
- Status: accepted
- Date: 2026-11-02
- Milestone: M1
- Supersedes: nothing; extends ADR 0002, which deferred WebSockets
Context
M1 calls for WebSockets in and out, with connections parked across hibernation, and the chat-room example needs them. ADR 0002 deferred them because an exported message handler without a send API is dead weight.
Decision
Package version 0.2.0 adds, to actor-core:
- An imported
websocketsinterface:send(conn, message),close(conn, code, reason),connections(), withmessage=text(string) | binary(list<u8>)andsend-error=unknown-connection | backpressure. - Three exports:
on-websocket-open(conn, path) -> result<_, string>,on-websocket-message(conn, message),on-websocket-close(conn, code, reason).
Semantics:
- The host accepts a GET with
Upgrade: websocketon the actor’s path, answers 101, and asks the guest withon-websocket-open; an error closes the socket with 1008 and the error as reason. The guest never sees a socket, only a connection id. - Every message and the close are mailbox events, serialized with the actor’s requests and alarms, in arrival order per connection. The reader waits for each event before reading the next frame: that is the backpressure.
- Sockets are owned by the host, so they stay open while the actor hibernates; the next message (or any other event) wakes it. This is the “park on the host” option from the initial design’s open questions; the alternative (close and ask clients to reconnect) is kept for actor migration in M2.
- A connection id is only valid in the actor it was opened on:
send,closeandconnectionsare scoped to the calling actor. sendnever blocks the guest: 256 queued messages per connection, thenbackpressure(the message is dropped; the guest decides what to do).
Why not wasi:http upgrades
wasi:http 0.2 has no upgrade or bidirectional streaming story, and
handling sockets inside the instance would pin it in memory, which defeats
hibernation.
Consequences
- Breaking for guests at the binary level: components built for 0.1.0 lack
the new exports and must be rebuilt. Source compatibility is kept by
wasmkeep-sdk: the newActorhooks have defaults (refuse the upgrade, ignore messages and closes), and every example rebuilt unchanged. - Messages are text or binary; ping/pong is handled by the host. Close codes and reasons follow RFC 6455 (reasons truncated to 123 bytes).
- Migration note: rebuild your component against
wasmkeep-sdk(or regenerate bindings againstwasmkeep:actor@0.2.0) and implement the three hooks to accept WebSockets.
0004. A CLA, so the project can also be licensed commercially
- Status: accepted
- Date: 2026-11-02
Context
The project had to choose between a CLA and a DCO. A DCO is friendlier: a contributor certifies they have the right to submit their work, under the project’s license. A CLA also grants the maintainer direct, broader rights from each contributor, including licensing the whole codebase under other terms. I decided the project should keep the option to offer commercial licenses, so it asks for a CLA.
Decision
- Contributors sign an individual CLA (CLA.md) granting copyright and patent licenses that allow distribution under any terms, including commercial ones. Contributors keep their copyright.
- The core stays Apache-2.0; the CLA promises that accepted contributions stay available under Apache-2.0 (no relicensing away from open source).
- Enforcement by a CLA check on pull requests (the CLA Assistant Lite GitHub Action): a contributor signs once by posting a comment, and the signature is recorded in the repository. A corporate CLA is added when the first company contributes.
Consequences
- Some contributors avoid CLAs; the friction is one comment on the first pull request, and the reason is stated plainly in CONTRIBUTING.
- The name is protected separately (TRADEMARK.md).
0005. Replicate as LTX v3 files, with our own codec
- Status: accepted (2026-11-02: the codec round-trips against
superfly/ltxv0.5.3 in CI) - Date: 2026-11-02
- Milestone: M2
Context
M2 ships each actor’s committed changes to the bucket and restores them on another node. The open question was whether to reuse the LTX format (Litestream and LiteFS) for tooling compatibility, or a simpler format of our own.
Findings (2026-11-02):
- The reference implementation (
github.com/superfly/ltx, Go) is at format version 3:LTX1magic, 100-byte header, 6-byte page header with optional per-page LZ4, a 16-byte trailer with a CRC-ISO-64 file checksum, and rolling pre-apply / post-apply database checksums. - The only Rust crate,
litetx0.1.0 (Fly.io), has one release from 2023-09 and implements the older 4-byte page header: it cannot read current files. - Whatever we ship needs the same ingredients anyway: page-level transaction
files, a monotonic transaction id, integrity checksums (on every segment,
and
wasmkeep bucket verify), and snapshots.
Decision
- Segments are LTX v3 files, written and read by our own codec in
wasmkeep-storage(no dependency onlitetx). - Conformance is tested, not assumed: CI generates files with the Go
ltxtool and checks we decode them, and checks the Go tool verifies files we write. Until that passes we do not claim compatibility. - Fencing does not live in the file format: the lease generation is part of
every object key (
wal/<generation>-<txid>.ltxas first planned; the final key format is in ADR 0007), and conditional writes reject stale generations. - Page payloads use LZ4 (
lz4_flex, pure Rust) as LTX v3 does; snapshots are LTX snapshot files (all pages), not zstd copies of the database.
Alternatives
- Own format: about the same amount of code once checksums and snapshots are included, and no tooling. Rejected.
litetxcrate: unmaintained and incompatible with current LTX.- Ship raw SQLite WAL files: WAL frames carry salts and checksums tied to one WAL file; restoring across checkpoints is fragile. LTX exists to fix this.
Consequences
- Operators can inspect and verify our bucket with Litestream’s
ltxtool. - We own a codec of roughly 600 lines plus tests; the spec is short and stable.
- New dependency when implemented:
lz4_flex. CRC-ISO-64 is small enough to implement directly with a test vector.
Conformance notes (2026-11-02)
conformance/ltx(Go, pinned tosuperfly/ltxv0.5.3) writes the fixtures incrates/wasmkeep-storage/tests/ltxand verifies the files our encoder writes. CI runs both directions and fails if the reference starts writing different fixtures.- Files are not byte-identical across implementations: LZ4 output differs, and the file checksum covers compressed sizes and index offsets. Content, headers and both database checksums are identical.
- We do not read LZ4 frame-format pages (older writers); they are rejected as unsupported rather than misread.
0006. Deterministic simulation tool
- Status: accepted: madsim, after the bake-off below (it reversed the initial proposal)
- Date: 2026-11-02
- Milestone: M2
Context
The project requires a deterministic simulation suite for leases, failover and replication, built before the replication code, with seeded runs and fault injection, 200 seeds per push and 20,000 nightly. There are two candidates, madsim and turmoil, and the plan is to try both on a toy lease protocol first.
What we know before trying:
| madsim 0.2.34 | turmoil 0.7.2 | |
|---|---|---|
| Model | Replaces tokio, time and randomness under --cfg madsim; deterministic task scheduling | Simulated hosts and network on top of real tokio, one thread, seeded |
| Intrusiveness | The whole build switches runtime; crates that spawn OS threads or do real I/O (SQLite blocking threads, Wasmtime’s epoch thread) break determinism | Only the code under test must use turmoil’s network and time; the rest of the workspace is untouched |
| Faults | Kill and restart nodes, partitions, latency, clogging | Hold and release links, partitions, latency, crash and bounce hosts |
| Maintenance | Active (2025-10) | Active (2026-04, tokio-rs) |
Either way, the cluster logic must not touch the network, clock or bucket
directly: it is written against small traits (Bucket with conditional put,
a clock, a node-to-node transport), so the simulation can drive it.
Initial proposal (superseded by the results)
turmoil, for being less intrusive than madsim’s global runtime swap.
The bake-off
- A toy lease protocol on a
Buckettrait (conditional create/update with versions, as S3 and MinIO provide), three nodes, a TTL and renewals. - The same protocol run under both tools with partitions, crashes, delays and a slow bucket, checking the invariant “never two live owners”.
- A deliberately planted bug (renewal without the version check) must be found by both within the per-push seed budget, and a failing seed must reproduce exactly.
- Record: time per 1,000 seeds, lines of harness code, whether failures reproduce, and anything that broke determinism. The ADR is then accepted or amended with those numbers.
Results (2026-11-02)
The same protocol code (sim/src/lease.rs then, now
crates/wasmkeep-cluster/src/lease.rs, written against Bucket and
Env traits), the same bucket semantics, the same seeded fault schedule
(links held then released for up to 2.5 s, which is the late-write case,
partitions, node crash and restart), 3 nodes, 20 s simulated per seed, debug
build, 4 vCPU container:
| turmoil 0.7.2 | madsim 0.2.34 | |
|---|---|---|
| Correct protocol, 200 seeds | no double ownership; 1,461 ownership changes | no double ownership; 1,552 ownership changes |
| Planted bug (blind renew) | found at seed 1 | found at seed 1 |
| Failing seed reproduces | yes, identical history | yes, identical history |
| Wall time for 200 seeds | 127 s | 1.3 s |
| Build | plain cargo test | RUSTFLAGS="--cfg madsim", own target dir |
Both find the bug and both are deterministic. madsim is about 100 times faster because it jumps virtual time to the next event, where turmoil steps a fixed 1 ms tick. At the nightly budget (20,000 seeds) that is about two minutes against several hours, and it leaves room for far more seeds.
The intrusiveness concern did not materialise: it only matters for code that runs under the simulation, and the simulated code is protocol logic behind traits, with no Wasmtime, SQLite or network dependencies.
Building the turmoil harness also surfaced a harness bug worth remembering: a simulated bucket that served one connection at a time let a single held client block everyone (turmoil reported the backlog overflow). A simulated dependency must be as concurrent as the real one, or it hides races.
Decision
- madsim for deterministic simulation; the turmoil harness was removed so only one is maintained.
- Cluster protocols (leases, replication ordering, fencing) are written as
logic generic over
Bucket,Envand transport traits, in modules that compile without tokio, hyper or object_store. Production adapters (object_store, tokio) implement the traits outside those modules. This is what lets--cfg madsimbuild them. - CI runs the simulations with 200 seeds on every push; the nightly run uses 20,000 (release build).
0007. Replication protocol: chained segments and the acknowledgement rule
- Status: accepted (2026-11-02: the madsim replication case passes, and removing the acknowledgement rule is caught)
- Amended by ADR 0020 (2026-11-07):
ownership is a node lease plus an ownership record per actor; where this
record says lease, read the actor’s hold (
LeaseView: the hold exists and the node lease is valid). - Amended by ADR 0022 (planned, 0.4.0): history keys move under the app’s root,
state/<tenant>/<app>/. - Date: 2026-11-02
- Milestone: M2
Context
Leases (wasmkeep_cluster::lease) give each actor at most one owner at a time,
under the skew bound. Replication must turn that into the guarantee
WasmKeep promises: a write acknowledged as durable is never lost, and a
stale owner can never corrupt what the next owner restores.
Two facts make this harder than “upload files”:
- Ownership is time-based. An upload started while the lease was valid can complete after it expired, after another node took over and listed the bucket (a slow PUT that still succeeds).
- A conditional create of a new key succeeds for a stale owner too. Key names alone do not fence.
Decision
Capture
Actor databases run in WAL mode with automatic checkpoints off. After each
event commits, the replicator reads the WAL frames appended since its last
position, keeps the last version of each page up to the last commit frame,
and encodes one LTX file (ADR 0005) for the next transaction id. Pre- and
post-apply checksums come from per-page checksums kept in memory. Once a
segment is uploaded and the WAL holds only shipped frames, the replicator
checkpoints (TRUNCATE).
Keys: every segment names its parent
actors/<tenant>/<class>/<name>/
wal/<txid:016x>-<generation:016x>-<parent generation:016x>.ltx
snapshot/<txid:016x>-<generation:016x>.ltx
A segment is transaction txid written by lease generation generation,
applied on top of the segment at txid - 1 written by parent generation
(0 for transaction 1, on the empty database). The objects therefore form a
tree of histories. A snapshot is the database at one position of that tree.
All writes are conditional creates; fixed-width hex makes listing order
transaction order.
Acknowledgement rule
A segment counts as durable only if its upload completed while this
node’s grant was still valid (grant.is_valid(now) checked after the PUT
returns). exec-durable waits for that. An upload that completes too late is
reported as a failure, and the node stops acting as owner; the write may or
may not survive, which is what an unacknowledged write is allowed to do.
Restore
- The head is the newest segment of the highest generation in the listing.
- Follow parent links back to the empty database, stopping at the first snapshot on the way.
- Apply that snapshot, then the segments after it. Each segment’s pre-apply checksum must equal the running database checksum.
- A missing link or a checksum mismatch stops the restore: the actor is marked degraded and refuses events rather than silently rolling back.
- (Added 2026-11-07.) The restore lists the prefix once, after acquiring, and fetches the objects that listing names. Every read is given up when the bucket stays silent for 5 s (no answer, no next chunk of a body, no next page of a listing) and sent again, up to three times; then the restore fails and the next event restores from a fresh listing. A request the bucket leaves hanging (one held a takeover for 19.8 s in the real-bucket soak) costs seconds, not the client’s 30 s per attempt. A read given up is dropped whole before anything of it is applied.
- (Added 2026-11-08.) The restore reads up to 8 objects ahead of the one it applies, within 32 MiB of them by the listing’s sizes (always at least one), and a read is also given up, and sent again, once it has taken 5 s plus a second per started megabyte of everything read alongside it (its own size included) from its request, however it keeps moving. An object whose size is not the listing’s is refused. One node’s restores together read at most 32 objects at once, and at most 256 MiB of them at once by the listing’s sizes, one read taking at most 32 MiB of that however large its object (one kill starts many restores at once; a larger share would let one read hold every other restore behind it). Objects read wait to be applied in each restore’s own read-ahead (memory stays per restore). A read’s time starts once it has its turn, and counts every byte the node is reading alongside it, asked again while it lasts. Objects are still applied one at a time, in the plan’s order. In the 12-hour soak one restore of 55 objects took 56 s, each read about a second, none silent for 5 s.
- (Added 2026-11-11.) A read the bucket refuses with an error a resend may get past is sent again after a pause (0.5 s, then 1 s), within the same three tries: a GET changes nothing, so a resend is always safe. (Corrected 2026-11-12.) During the pause the read gives up its place under the node’s limits of step 6 (its slot, its megabytes, its bytes counted in flight), which it used to hold while it only waited, and waits for one again before the resend; that second wait is not in the deadline below, as the first is not. An answer no resend changes (the object is gone, access is denied) still fails the restore at once. A restore’s reads together end within one deadline: 30 s, plus a second per started megabyte of the objects it reads (the rate below which one read is given up) and a second per eight objects. Its clock runs while the restore waits for the bucket to give it the object it applies next, and only then: not during the listing (its length is not known before it ends; it keeps the rule of step 5), not while an object is applied or the file is put in place (local work, never cut), and not while a read waits for the node’s shared limit on reads before it is asked for (many restores start at once after a kill, and a deadline counting their queue would end them all half-way). (Corrected 2026-11-12.) The deadline is sized from the restore’s own bytes, but a read’s limit (step 6) counts every megabyte in flight on the node, so restores reading side by side were each ended by their own deadline with every read inside its limit (32 actors of 8 MiB taken over at once: 261 s per read, 39 s per restore), and tried again together. For each object, the seconds that other restores’ bytes in flight added to the limits of its read’s tries (the limit as it is, less the limit with this restore’s bytes alone, most seen during the try) are therefore not counted, up to the time the restore waited for that object. What is counted for an object is then at most what its tries are allowed with no other restore on the node, so other restores’ reads never end a restore; one alone on the node has exactly the deadline above. Worst case: the deadline above plus 768 s per object (three tries, at most 256 s each from the node’s 256 MiB in flight), which the reads’ own limits already allowed; a bucket that never answers still ends a read, and the restore, after three silences of 5 s. A restore that fails before its database is complete leaves the local file and its sidecar untouched (the database is built beside it). One that fails while putting the complete database in place (a local disk error: the old file is set aside and its sidecar removed, the new file renamed in, its sidecar written) has set the old file aside, and leaves no file in place or the new one without a sidecar. Neither is used as it is: a local file spares the reads only when a sidecar names the chain’s head and the file’s checksum is the one the sidecar records, so the next restore reads the bucket again (and, if the new file was in place, sets it aside over the file set aside before: one generation is kept). In every case a failed restore writes nothing to the actor’s history and gives the lease up as a failed restore did before: the next event restores from a fresh listing, so it adds no ordering the protocol did not already have.
Why every acknowledged write is on that chain: generation h acquires only
after every earlier grant ran out or was released, and lists after
acquiring, so it sees every acknowledged upload. An acknowledgement needs the
upload to have completed while the grant was valid; a node releases only
after the actor’s state is dropped, so after its last upload completed and
with nothing left to acknowledge. Either way, every acknowledged upload is in
the bucket before h can acquire (S3 listings are strongly consistent). The highest generation m in its listing restored
a chain containing everything acknowledged before m began (by induction),
and only m could acknowledge anything after that; generations between m
and h wrote nothing. So h restores, and builds on, a chain containing
every acknowledged write. A stale upload that lands later is on no chain
anyone built on. If nobody wrote after it, it extends the chain it was built
on and counts as a late commit, which is allowed for an unacknowledged write.
Releases make that window shorter: an upload cut short by the event’s
wall-clock limit can still land after the next owner listed, if the node
gave the actor up and released right after.
The simulation (sim/tests/madsim_replication.rs) checks this with owners
that release (as on hibernation, shutdown and failed uploads), and catches a
node that keeps working and acknowledges after releasing.
Snapshots and GC
Every 64 segments or 64 MB, the owner uploads a snapshot of its head. Snapshots off the current chain are never used. GC deletes segments and snapshots that are not needed to restore the current head, after a retention window, never on the write path.
Alternatives
- Start markers with fencing (the first version of this ADR): each new
generation writes
start/<g>with the txid it restored to, and restore drops lower-generation segments above it. The simulation broke it. A marker can itself land late: generation B’s marker upload is slow, B’s grant runs out and B never runs, generation C restores without seeing it and builds on a segment, and then B’s marker lands and fences off that segment. The result is a hole under C’s acknowledged writes (seed 10). Chained keys need no markers at all. - Highest generation per transaction id, no links: this cannot tell a stale snapshot from a current one at the same transaction.
- A per-actor head object updated with compare-and-swap after every segment: this fences exactly, but doubles the bucket round trips of every durable write.
Consequences
sim/tests/madsim_replication.rsruns three nodes on the real lease and planner code, with crashes, held traffic, skewed clocks and slow requests that succeed late. Over 200 seeds of 30 s: about 5,700 acknowledged writes, 2,300 ownership changes and 97 uploads landing after their grant, with every acknowledged write restored. Acknowledging late uploads is caught (seed 20; seed 24 since snapshots record their contents and are also taken right after a restore, 2026-11-05). The test also fails if late uploads stop occurring, so it cannot pass vacuously. Garbage collection (ADR 0012) runs in the same simulation. Since 2026-11-07 it runs on node leases (ADR 0020,wasmkeep_sim::node, whose holds act by the server’s own rule), with the lease on a task of its own and its writes bounded as the server bounds them: over 200 seeds, 7,061 acknowledged writes, 1,073 generations, 31 uploads landing after their hold ended and 796 fences, every write restored (2,000 seeds: 69,952 writes, 7,892 fences); acknowledging late uploads is caught at seed 0, after a release at 2, after a lapse ended the hold at 0.- Restore planning is a pure function of key names, in
wasmkeep-cluster. - Clock skew beyond the configured bound can break the acknowledgement rule, exactly as it can break leases. The bound is a documented operating requirement (NTP).
- The simulation found a harness bug on the way: restarted nodes reused reply tags, so a dead process’s late reply could answer a new request. Tags now start at a random offset per client.
0008. wasmkeep deploy and rolling upgrades through the bucket
- Status: accepted (implemented 2026-11-02; the mixed-version test refuses old code, and removing the check fails it)
- Date: 2026-11-02
- Milestone: M2
Context
The last M2 item on the roadmap: wasmkeep deploy
uploads artifacts and manifests, and nodes roll to the new version without
dropping actors. Today each
node is started with --actor CLASS=PATH and runs whatever files it was
given. A cluster needs one source of truth for which code runs, with no
control plane besides the bucket (a design rule, see the
overview: if the bucket is up, WasmKeep is up).
What already exists:
ActorService::replace_classswaps a class at the next event boundary. Running events finish on the old code, databases and sockets are kept, andon-initruns once per component version.- The component cache compiles each artifact once per node.
- Leases make an actor run on one node at a time.
The new hazard is mixed versions during a rollout. Nodes pick up a new
version at slightly different moments. Suppose an actor runs on-init for
version 7 on node A (migrating its schema), then moves to node B, which still
runs version 6. B would run old code on a migrated database.
Decision
Bucket layout
apps/<tenant>/<app>/<version>/<class>.wasm as uploaded, immutable
apps/<tenant>/<app>/<version>/manifest "wasmkeep-manifest 1", previous <version>, class <name> <sha256>
apps/<tenant>/<app>/current {version, seq}; replaced with If-Match only
The manifest is a few lines of text (no JSON dependency); seq lives in
current. version is the first 16 hex digits of the SHA-256 of the class table, so a redeploy of the
same code is a no-op. seq is a counter that increases with every change of
current, including rollbacks.
wasmkeep deploy
- Compile every component locally and link it against the actor world, with
the same check
serveruns. A broken component is refused before anything is uploaded. - Upload the artifacts and the manifest with conditional create. They are immutable; an existing object with the same key is the same content.
- Read
current, then replace it with{version, seq: old + 1}, usingIf-Matchon the version read. A concurrent deploy fails loudly instead of silently overwriting.
wasmkeep deploy --rollback re-points current to the manifest’s
previous, with a new, higher seq. Code never goes back in seq, even
when it goes back in content.
Nodes
wasmkeep serve --bucket ... --app <tenant>/<app> replaces --actor. The
node reads current at start and with every heartbeat (3 s). When it
changes, the node:
- downloads and compiles all the new classes;
- swaps them in, and the
seqwith them, at once (apply_deploy).
A node that cannot build a version keeps the previous one, whole, and reports the error in its heartbeat.
No old code on newer data
Each actor’s database records, in _wasmkeep_meta, the highest deploy seq
that has run an event on it. A node never runs an event on an actor whose
recorded seq is above its own. Instead it re-reads current at once and
upgrades first. If it still cannot (the new version fails to build on it),
it refuses with 503 and Retry-After, so the actor is never downgraded. The
check costs one meta read per activation, not per event.
Rollback stays safe: rolling back produces a higher seq, so it is allowed
everywhere. Schema compatibility on rollback is the application’s job, as
it is for any schema migration.
Alternatives
- Push to nodes (deploy calls every node). It needs node discovery and reachability from the deploy machine, and it fails partially. Polling the bucket fits “the bucket is the only shared state”.
- A version per actor in the lease record. That couples deploys to the lease protocol, which is proven and should stay small. The meta row lives with the data it protects and is replicated with it.
Consequences
- A rollout takes about one heartbeat interval (3 s) to reach every node. Actors switch at their next event, and none is dropped.
- New CLI command, new
servemode, a manifest format and a test: two nodes, deploy v1, write, deploy v2 with anon-initmigration, move the actor to the node that hasn’t polled yet, and assert it upgrades first or refuses rather than running v1. - Artifact garbage collection (old versions) joins the bucket GC work.
Implementation notes (2026-11-02)
- Classes dropped by a new version (or a rollback) stop being served: new requests get 404, and their actors’ databases are kept.
- A node that refuses an actor answers with an error and wakes its follower at once; in the test the node caught up and served within a second, instead of waiting for its next poll.
Revision (2026-11-04): the sequence travels with the code
The node’s sequence was read when an actor recorded it, separately from the class the event had taken. Classes were replaced one by one, then the sequence raised. That left three ways to break “no old code on newer data”, found from a 1-in-80 test flake under load and an adversarial review:
- Old code under the new sequence. An event took the v1 class, then waited (database open, instantiation); deploy 2 landed meanwhile; the event read sequence 2 and recorded it, running v1.
- New code under the old sequence. An event between “class replaced” and “sequence raised” ran v2 and recorded 1. A resident instance never re-recorded, so a node still on deploy 1 could later take the actor over and run v1 on data v2 had written.
- Half a deploy. A class that failed to build left the ones before it replaced and the sequence not raised, indefinitely (retried, and torn down, every poll).
Now each class carries the sequence it was swapped in under. An event takes
both together, and records the class’s sequence, not the node’s. A deploy
is compiled whole, then swapped in with its sequence under one lock. An
instance records again whenever its class’s sequence moves past what it
recorded (the same bytes redeployed keep their instances). A class no
longer served refuses events, including for actors already resident.
Tests: crates/wasmkeep-server/tests/deploys.rs, mutation-checked; a storage fault-injection delay
holds an actor’s start-up while a deploy lands.
A second review round added: classes are stamped with their sequence when
the node starts (ActorConfig::deploy_seq), so nothing runs unchecked
before the follower’s first update; a class removed by a deploy closes its
resident actors’ sockets (1012) and drops their alarms from the timer
(they stay in the database and the bucket’s index); the follower backs off
after a failed update (up to 5 minutes, wake-ups included) instead of
recompiling every poll. A node serving --actor classes (sequence 0) does
not check sequences at all: do not point one at a bucket that deployed
apps use.
Revision (2026-11-05): rules, configuration and lineage
ADR 0013 adds access rules
and plain configuration to the version (manifest format 2), puts
previous into the version id (the class table alone made a directory
reused under a stale previous), and adds a sticky third field to
current that tools and nodes from before refuse.
0009. Alarms in a cluster: an index in the bucket and a sweep
- Status: accepted (implemented 2026-11-03, revised after three rounds of adversarial review the same day; index keys tagged with the owner’s generation and a nonce on 2026-11-05, closing a race with deletes that land late; R4, an alarm from before joining listed before the first upload, the same day). Mutation-checked tests cover the sweep delivering an orphaned alarm, sweeps getting past stuck entries, the cursor under an escaped prefix, committed retries, backoff for handlers that reschedule and fail, a fresh backoff per alarm, recording the listed entry, stale-entry cleanup, late deletes after a takeover and after an owner’s own reschedule (the alarm still delivered by a node that never ran the actor), the owner’s generation in new keys, and legacy entries next to tagged ones. Not covered by tests: listing from
exec-durable, giving the actor up after a failed listing, the timer’s backoff horizon, resuming after the delivery cap, and sharding when nodes disagree; R1 to R4 and the fence are also checked under deterministic simulation (sim/tests/madsim_alarm_index.rs): three owners in turns on node leases (wasmkeep_sim::node, ADR 0020: holds, fences, releases), state uploads following replication’s late-commit rule, requests applied late, deletes timing out and landing later, crashes, with the invariant that a committed alarm’s named entry exists checked every 50 ms. Some nodes join with an alarm in their own data. 200 seeds per push; untagged keys, no generation check, no fence, a first upload before its entry, and clearing while the bucket has nothing for the actor are each caught by some seed (the last two with a budget of 2,000 seeds; the empty-bucket case is reached by a fault aimed at a joining node’s first upload, and a separate test shows the rules keep every entry under that fault). Cluster tests cover R4 (an_alarm_from_single_node_data_is_indexed_before_its_first_upload) and the entry left while the bucket has nothing (an_entry_for_an_actor_the_bucket_never_had_stays_without_creating_it). Not modelled there:exec-durable’s mid-event index update, deleting an actor, the PUT retry, legacy migration and timer delivery (cluster tests cover those). One known exception to the invariant as the simulation states it: if recording the new entry in the database fails after its PUT, the event is still replicated naming the previous entry (at another time), while the new, unnamed entry lists the alarm; nothing is lost (that entry is delivered, then removed as unnamed). - Amended by ADR 0020 (2026-11-07): ownership is a node lease plus an ownership record per actor; where this record says lease, read the actor’s hold (
LeaseView: the hold exists and the node lease is valid).sim/tests/madsim_alarm_index.rsruns on node leases since 2026-11-07 (wasmkeep_sim::node, holds acting by the server’s own rule): over 200 seeds, 1,511 takeovers, 3,993 alarms and the invariant held throughout (2,000 seeds: 15,086 takeovers); every unsafe variant is caught, within 20 seeds. On node leases a takeover during a joining node’s first upload needs that node cut off and fenced (its lease keeps renewing while the upload is only slow), so “clearing while the bucket has nothing” is reached by a fault aimed at that upload (delayed at the bucket, the node cut off), under which the rules also hold. - Amended by ADR 0022 (planned, 0.4.0): the alarm index moves under the app’s root,
state/<tenant>/<app>/. - Date: 2026-11-03
- Milestone: M3
Context
An actor’s alarm lives in its own database (_wasmkeep_alarm), which
replication carries to the bucket. What makes it fire is each node’s
in-memory timer, filled from events the node runs and, at startup, from a
scan of its local data directory.
In a cluster that left a gap: no node knew about alarms of actors it had never run. If the node holding an idle actor with a pending alarm died for good (or, since clean shutdowns hand actors over, was removed), the alarm never fired until some request happened to reach the actor. Separately, a node that was not the owner re-armed the alarm every lease period forever, and a failing handler’s retry schedule lived only in memory, so it reset on hibernation.
Decision
The actor’s database stays the source of truth. The bucket gets an index of pending alarms that any node can read cheaply, and every node sweeps it.
- Index entries.
alarms/<tenant>/<due ms, 15 digits>/<class>.t/<name as hex>/<generation, 20 digits>-<nonce as hex>, empty objects. Due time first and zero-padded, so a listing (S3 lists in key order) yields the earliest alarms first; the name as hex, so nothing in the key is escaped by the object store (some stores percent-encode or reject characters in keys). The last segment (the tag) is the lease generation of the owner that wrote the entry and a nonce that process never used before, so every write uses a new key. Entries written before tags existed arealarms/<tenant>/<at>/<class>/<name as hex>(“legacy”); readers accept both. Tagged entries sit in a sibling folder (<class>.t; class names have no.), never under a legacy key: no key is both an object and the folder of others, which MinIO cannot hold. The key format and the rules below are inwasmkeep_cluster::alarm_index, pure functions the simulation can share. - The owner keeps the index equal to the committed alarm. After an
event, the owner lists the committed alarm before replicating the
event, and removes the entry it listed before after replicating it, so
a crash in between leaves an extra entry, never a missing one. The exact
entry it listed is recorded in the database (
alarm_indexedmeta:<at>/<tag>, empty for none; a bare<at>is a legacy entry), so the next owner, on any node, knows which entry stands for the alarm. An unchanged alarm keeps its entry, whichever owner wrote it; a legacy one is replaced by a tagged one on the next event (migration). Only while its lease is valid and replication is healthy, so what the database names is in the bucket. If the index cannot be brought in line (the PUT fails, the recorded entry cannot be read or written, the lease lapsed), the node gives the actor up, as for a failed upload, rather than replicate an alarm no other node could find.exec-durable, which uploads in the middle of an event, lists a pending alarm first too, under the generation of the replica it uploads with. Three rules make a delete that lands late (an S3 retry, a slow network) harmless:- R1. A fresh key for every PUT. No entry is ever written again (except the immediate retry of the same PUT), so no delete sent earlier can remove a newer entry.
- R2. Delete only what the replicated state no longer names. The
entry an event replaced, once that event is replicated; or, when an
alarm delivered for a time no longer matches the committed alarm, the
actor’s entries at that time other than the one the database names.
Entries older owners wrote there are named by no state: the owner
restored the latest, and keys are never reused. “The latest” holds only
once the owner’s own upload ends the history: until then an earlier
owner’s upload that lands late is on no chain anyone built on, so it
becomes the state after all (a late commit, ADR 0007), and it may name
an entry about to be removed. So an owner that has uploaded nothing yet
uploads a one-line change (
owner_fencemeta) before it removes entries as stale (found in review): at most one extra upload per hold. An owner that found nothing in the bucket for the actor, and has uploaded nothing since, removes nothing: an earlier owner’s first snapshot can name an entry (R4) and still land. It does not fence by uploading either: that would create the actor in the bucket, empty, and the joining node whose snapshot never landed would later restore that empty state over its own data (no sidecar matches, so the bucket’s version is downloaded and its own file set aside as.replaced). It retries such an entry after the longest backoff (retry_max, 5 min) rather than on every sweep, and releases the actor’s lease at once: held until the idle timeout, it would route client requests to this node, whose first would create the actor empty in the same way (as any request reaching another node first already can, see cluster: start the node with the data first). - R3. Never delete an entry with a generation above one’s own. A newer owner wrote it (the owner’s lease ran out between its check and the listing).
- R4 (not about deletes). An alarm from before joining is listed before the first upload. A node that ran on its own and joins a cluster uploads each actor’s database as its first snapshot when the bucket has nothing for it. If that database holds an alarm, the node lists it under a fresh key and records the entry in the database first, so the snapshot names an entry that exists; if it cannot, nothing is uploaded and the alarm stays on that node only. (Before 2026-11-05 the snapshot went first: a node cut off before the actor’s first event ended left an alarm in the bucket that no entry listed.)
- Retries are committed. When
on-alarmfails, whatever alarm the handler left due before the retry time (the same one, or one it set for now) moves to the retry time (1 s, doubling to 5 min); one it set later, or a clear, stands. The failure count is in the database, read before the handler runs; setting or clearing an alarm outside a failed run resets it. So the backoff survives hibernation and moves, a handler that reschedules itself and fails still backs off, and the index lists the retry time: no sweep delivers it early. - Sweep. Every
alarm_sweep(10 s) each node reads its tenant’s index up to “now minus a lease period plus grace”, stopping at the first entry not that overdue. It reads at most 2048 entries and resumes after the last one next time, so entries nobody can act on (a class no node serves, an actor that cannot start) cannot hold the rest back. The live nodes (fresh heartbeats) share the entries by a hash of the address, so a dead node’s alarms are recovered in parallel; an entry overdue three times longer is anyone’s. Each node delivers at most 256 per sweep through its timer, where a time it already holds earlier, or within the longest backoff (a retry), wins. Its own entries go first; anyone’s entries the walk met keep an eighth of the 256, taken from a random rotation, and take what own entries leave; entries the node already has in flight or retrying do not count (plan_sweep; an earlier walk, first come, first served, had every surviving node deliver the same anyone’s entries). Delivery is the ordinary alarm event: take the actor (restoring it from the bucket), read the committed alarm, runon-alarmif due. Entries at a time that does not match the committed alarm are stale and are removed (R2, R3), after the committed alarm is indexed; that costs one LIST of the actor’s tagged entries at that time (the legacy key is deleted by its exact name). This works without the instance too: after a trap, or onceon-alarmdeleted the actor, the entry spared is the one the replicated state names (none, after a delete). An entry delivered for an actor with no state (no committed alarm and noon-initrecorded: deleted since, or never initialised) is removed without running the actor, which would only create it again. - A node that cannot run an alarm because another node owns the actor drops it: the owner has it, committed and indexed. If the bucket is unreachable it retries after a lease period. (A first version listed the alarm in the index instead; the review showed that a stale local copy of a deleted actor could then bring the actor back.)
Consequences
- An alarm whose owner dies fires on another node a lease period plus grace after it was due, plus up to one sweep: about 25 s with the defaults.
- One extra PUT per event that changes the alarm (plus a DELETE of the old entry); none for events that leave it alone, even under a new lease generation. A sweep reads only overdue entries.
- An entry left behind (a crash between the PUT and the upload, a PUT that landed after it was given up) costs one wake-up when it comes due, then is removed. For an actor deleted since, the wake-up restores its empty database only to read it; the actor is not run.
- An alarm set by a plain
execevent is as durable as that event’s writes. - During retries
get-alarmreturns the next attempt’s time. - Single-node servers keep their behaviour, except that retries are now committed (and so survive restarts).
What can still go wrong
- Alarms set before this change, on actors no event reaches, have no entry until an event moves them. (Pre-alpha: no such deployments.)
- During a rolling upgrade, nodes still on the old code change alarms
without updating the index. Nodes from before tags (2026-11-05) skip
tagged keys when sweeping (and read the whole index each sweep, since
they never reach a key they parse as not yet due), read a tagged
alarm_indexedas “nothing listed” and add a legacy entry, and keep the unfenced late-delete race for legacy keys: roll all nodes forward together. Rolling back leaves alarms of idle actors that only have tagged entries invisible to the old sweeps until an event reaches the actor. Old nodes also never remove tagged entries, and leave a taggedalarm_indexedin place when they clear an alarm, so each actor that had an alarm when the rollback started keeps one tagged entry until the cluster rolls forward. While the rollback lasts, old sweeps step over the past-due ones on every sweep. After rolling forward, each leftover costs one or two wake-ups (within the usual 256 per node per sweep) and is then removed, since its generation is lower. - Entries for classes no node serves stay in the index (a few bytes each); sweeps step past them.
- An entry listed by a joining node that died between the PUT and its
first upload (R4) stays while the bucket has nothing for the actor: a
wake-up every
retry_maxon the node that delivers it. It resolves when that node comes back and uploads the actor (the alarm then fires as usual), though that node lists a new entry of its own, and the old one goes only on a sweep after the alarm fired; if the node never comes back, the entry stays. In the same corner, an alarm whose first snapshot lands late can fire up toretry_maxlate. Each failed attempt at a first upload (the PUT, recording it, or the snapshot) leaves one more such entry, all at the alarm’s time, each cleared after it fires. - Recovery speed: each live node delivers up to 256 alarms per sweep (10 s), so a dead node’s backlog drains at about 25 alarms per second per live node.
- Bounded delays: an entry whose shard falls to a node that does not serve its class, or while nodes disagree about who is alive, waits until it is three margins overdue (about 45 s with the defaults); an orphaned alarm can wait behind a stale registration on the sharded node for up to the longest backoff (5 min).
Alternatives
- A conditional delete of the observed object version: the object_store version we pin takes only a path for a delete (no precondition), and sends it to S3 as a DeleteObjects request.
- Deleting only after a settle time longer than any request can be in flight: it adds a clock assumption (the skew between any two nodes is up to twice the per-node bound), a new table hidden from guest SQL, and more code paths than tagged keys.
- Every node scans every actor’s database in the bucket: one GET per actor per sweep; does not scale.
- The due time in the object body, or last in the key: a sweep would have to read every entry, due or not (the first version did this; the review caught it).
- A per-node list of owned actors: the dead node cannot hand it over.
0010. Who may create an actor
- Status: accepted (implemented 2026-11-04, revised after an adversarial review the same day). Tests, mutation-checked:
tests/access.rs, single node:- unknown names answer 404 and leave no database;
- an actor creates one, after which it is reachable;
- deleted, it is unknown again;
- a deleted actor’s sockets are closed and cannot bring it back;
internalanswers actors only;- sockets are refused the same way.
tests/cluster/,existing_access_checks_the_bucket_and_leaves_nothing_for_unknown_names, under a bucket prefix and with an escaped name:- no lease or snapshot for an unknown name, over HTTP and WebSocket;
- an actor created on one node is reachable through another;
- an
internalactor answers a call from another node, and refuses outside requests even with forged node and caller headers.
- Date: 2026-11-04
- Milestone: M3
Context
There is no “create” step: the first event to an address creates the actor and its database. For rooms and counters that is the point. For classes whose actors another actor manages, it is a hole, found building Watch (dogfood, frictions 8 and 9):
GET /actor/monitor/<anything>created an empty monitor: a database on disk and, in a cluster, a lease and a snapshot in the bucket. Leases are never deleted (their generation fences replication), so every name requested left an object in the bucket for good. Anyone who could reach the server could fill the disk and the bucket, one name per request.- Every such class re-implemented the same check in code, and forgetting it was silent: the monitor did.
Decision
A mode per class, --access CLASS=MODE on serve and dev:
open(the default): as before.existing: outside requests reach only actors that exist. A new name answers 404, and nothing is created: no database, no lease, nothing in the bucket. Other actors create them, withactors.call.internal: outside requests are refused with 403. Only actors (calls) and alarms reach the class.
“Outside” is a request arriving at a node’s front door. Calls between
actors are inside, including calls between nodes, which carry the
cluster secret. A client cannot pose as one: the caller header is stripped
from outside requests and the secret is checked as before. Requests forwarded to the owner do not carry the secret, so the
owner checks them again; the actor is resident there, so that costs
nothing. WebSockets always come from outside: the upgrade is checked, and
under existing and internal so is every socket event (in the same event
as the work, so a delete in between counts), and a delete closes the
actor’s sockets. A socket event never recreates a deleted actor of such a
class. Open classes keep their sockets on delete, as before.
Existence is decided without creating anything, before the lease. The actor exists if it is in memory on this node, has a database file here, or, in a cluster, has anything under its prefix in the bucket (one listing, read no further than the first key).
--access naming a class that is not served is an error: a typo would
leave the class open. With --app, the deployed classes count as served;
a class a later deploy adds cannot be named until the node restarts. Every
node of a cluster must get the same rules.
Consequences
- Watch runs monitors as
existing: the hub creates them, and dashboards read them and stream from them as before. - In a cluster, a deleted actor still counts as existing. A delete
leaves an empty snapshot in the bucket (so no node restores the old
data), and the check sees the prefix. An outside request can then wake
it as an empty actor, running
on-init, and a request that writes takes a new lease and ships new segments for it. This is limited to names that once existed (the fill-the-bucket attack stays closed), but in a cluster a delete is not a revocation. On a single node a deleted actor is unknown again. A delete marker would make the two agree; it changes replication, so it waits for a case insim/. - A miss in a cluster costs one bucket LIST. An attacker can drive that with random names (S3 bills LISTs). It is bounded by the request rate and creates nothing; a short negative cache per node would cut it, if the bill shows it matters.
- A refused name holds a mailbox in memory until the idle timeout (no database, no instance: a few hundred bytes).
- Singletons. A class with one well-known actor (
hub/main) could not create it underexisting, since nobody calls it.existing:NAME,...(added the same day) lists names outside requests may create; the rest of the class staysexisting. - Deployed apps (
--app) take the rules from the command line too; carrying them in the deploy manifest, so they travel with the version, is done: see ADR 0013.--accesswith--appis refused once rules are deployed. - If a node cannot read the cluster secret, calls from other nodes look
like outside requests: an
internalclass answers them 403. This fails closed.
0011. Mutual TLS between nodes, with certificates pinned in heartbeats
- Status: accepted; all steps done, step 11 (refuse by default) on 2026-11-06, before the first release (see “Plan”). Decided in order to close the known security gaps between nodes; the open questions below were answered with the recommended option and can be revisited (question 4 is still open, see below).
- Date: 2026-11-05
- Milestone: M3
Context
Nodes talk to each other over plain HTTP on the same port clients use:
forwarded requests, relayed WebSockets, and actors.call between actors on
different nodes. Calls carry an HMAC-SHA256 signature over their headers
and body (x-wasmkeep-internal: v4 ... since the call nonce; v3 when this
was written, with the cluster secret from the
bucket), so nobody can pass for a node or change a call without the
secret. But anyone on the network between nodes can read every request and
response, and change responses. The security model asks operators to keep
node traffic on a private network; that is the last open gap between nodes
(docs/security.md, “Known gaps”).
The original design planned mTLS with certificates issued at
wasmkeep cluster init: a cluster CA. Three designs were drawn up independently and judged against
the code (a CA in the bucket, operator-provided PKI, and certificates pinned
in heartbeats).
Decision
Each node is its own trust anchor, published through the bucket.
- At start, a node generates an ECDSA P-256 key and a self-signed certificate in memory (CN = node id). The key never touches disk or the bucket; a restart is a key rotation.
- Its heartbeat (
nodes/<id>, already the only place a node speaks for itself) gains two optional lines: thehttps://address of its cluster listener, and the SHA-256 fingerprints of its certificate (one, or two during a change). Older nodes ignore lines after the app, so a mixed cluster keeps working. - A peer is trusted if and only if its certificate’s fingerprint is in a live heartbeat under its own key. The bucket stays the root of trust, as it is for the cluster secret: whoever can write the bucket controls the cluster. Unlike a CA key in the bucket, nothing at rest lets a reader of the bucket (or of a backup of it) mint a node identity.
- Revocation is the heartbeat expiring (or being deleted): a removed node’s pin stops being trusted within the heartbeat horizon, and every request re-checks the peer’s pin, so a kept-alive connection is refused too.
A separate cluster listener. --cluster-listen ADDR (with
--cluster-advertise https://HOST:PORT) serves TLS 1.3 only, ALPN
http/1.1, with a mandatory pinned client certificate; it serves actor
paths and the health and readiness checks, nothing else. The public
--listen port is unchanged. Metrics stay on --metrics-listen.
TLS session resumption is off on both sides. A resumed session carries the client certificate from the original handshake without running the verifier again, which would let a removed node, or one whose pin changed, back in. Connections are pooled and kept alive, so full handshakes are rare.
Transport identity is not caller identity. The HMAC signature (v3, now v4 with a nonce) stays the only way a request counts as a node’s call (who is calling, the chain). TLS adds confidentiality and integrity in both directions and authenticates the hop. Requests forwarded for outside clients stay external, with the access check repeated on the owner. A request that arrived on the cluster port is never forwarded again.
Rollout without a flag day.
- Phase A: nodes restarted with
--cluster-listenpublish their TLS address and use mTLS to peers that do the same; plain HTTP is still used to (and accepted from) peers without it. - Phase B:
--cluster-plaintext refuse: signatures arriving on the public port are ignored (such calls are treated as coming from outside), and peers without TLS are not used. - Phase C (a later release):
refusebecomes the default when--cluster-listenis set. (Done 2026-11-06 in step 11, before any release rather than in a later one; a node with--node-idand no--cluster-listenrefuses to start unless given--cluster-plaintext accept.) - No plaintext fallback after a TLS failure: a peer that publishes a TLS address is reached over TLS or not at all (503, the caller retries).
rustls with the ring provider, named explicitly. Both ring and
aws-lc-rs are compiled into the dependency tree, so every config is built
with builder_with_provider; clippy.toml bans every rustls entry point
that would fall back to the process default (which panics with two
providers compiled in).
Consequences
- No
cluster init, no CA ceremony, no certificate files: two flags. - A new node’s pin reaches its peers with the next heartbeat listing (a
few seconds). Until then peers refuse its handshakes; it reports not
ready (
/_wasmkeep/ready) until its identity has been published for a heartbeat period, and a refused unknown pin makes the verifying node list heartbeats again sooner. - New dependencies:
rcgen(in-memory key and certificate generation) andtower-service(the connector trait hyper-util’s client requires; already in the lock through hyper-util). sim/needs no change: it models leases and replication, not HTTP, and heartbeats stay advisory (they drive placement, never ownership). Cross-node behaviour is tested incrates/wasmkeep-server/tests/cluster/and a newtests/cluster_tls.rs.- A guest allowed onto the node subnet with
--allow-outboundcan still reach the public port of other nodes, never the cluster port (it has no pinned certificate).
Open questions, answered for now
- Replace the original design’s certificates issued at
wasmkeep cluster init? Yes: pins need no CA and no init step. - Is “can read the bucket but not write it” a role worth protecting? Yes, which favours pins over a CA key at rest.
- Operator-supplied PKI (
--cluster-cert,--cluster-key,--cluster-ca)? Deferred; it would be a second trust root. - Should refuse mode stop publishing the plain address? Still open. Phase C (step 11) left the heartbeat unchanged, so the plain address is still published, and peers cannot see a node’s mode (step 8).
Plan
Each step lands on its own, with tests that fail before it; steps that change who is trusted get an adversarial review.
- This ADR.
- Heartbeat lines for the cluster address and pins (wasmkeep-cluster), no behaviour change.
- A connector for the three node clients, plain only, no behaviour change.
- Identity and pin verifiers, unit-tested, not wired.
- The cluster listener: one serve function owning both accept loops and
one shutdown; handshake timeout and connection cap. (Done: not wired
yet;
http::serve_with_cluster,ClusterPort.) - Wiring in accept mode, with the whole cross-node suite running over TLS.
(Done:
ClusterConfig::cluster_tlsandserve_with_cluster_listener, library only until step 7. Differences from the text above: a peer’s pins are looked up by thehost:portof the cluster address it published, at connect time; when two live heartbeats name the same address, the newer one is trusted there (a node restarted under another id), and neither on a tie. Every pin is trusted until its heartbeat’s write time plus the liveness horizon (ttl and allowed skew), checked against the clock, so trust lapses on time even if the bucket can no longer be listed. The cluster port checks the client’s pin on every request, and cuts an upgraded WebSocket once that pin stops being live (checked every 500 ms); a relaying node cuts its side the same way when the owner’s pin stops being trusted at its address. A node’s pooled connections are dropped (the clients rebuilt) whenever an address it knew changes pins or disappears, and nothing is sent to an address whose heartbeat expired, so a pooled connection is only reused to the same live identity. A certificate matching no pin (on either side) lists the heartbeats again sooner, at most once per heartbeat period; a refused or timed-out connection does not. So does a connection whose address’s heartbeat, as last listed, expired: it waits up to 3 s for a listing that shows a newer one before it is refused (a node whose own listing is late under load would otherwise refuse a live peer; an expired claim is never used). Readiness waits a period and a half after the identity was published, counted again after a heartbeat outage.serve_withrefuses a node configured for TLS, so a published cluster address is always served. The cross-node suite runs a second time, fromtests/cluster_over_tls.rs, with every node publishing a plain address nobody listens on.) - CLI flags. (Done:
wasmkeep serve --cluster-listen ADDR --cluster-advertise https://HOST:PORT, both or neither, only with--node-idand actors; both ports served with one shutdown. The cluster port’s connection cap is counted in the open-files budget before the public port and the resident actors: 1,024, or an eighth of the usable descriptors when the limit is low (fds::reserve_cluster_port,ServeLimits::cluster_connections). Without a listener a node still warns about a public--advertise; with one it does not. Tested with two real processes whose plain addresses lead nowhere,crates/wasmkeep-cli/tests/cluster_tls.rs, run in CI’s S3 job.) - Refuse mode. (Done:
wasmkeep serve --cluster-plaintext accept|refuse,ClusterConfig::cluster_plaintext(ClusterPlaintext::Acceptby default);refuseneeds a cluster listener (the CLI andActorService::newrefuse it without one). In refuse mode a signature is honoured only on a request that arrived on the cluster port; on the public port a valid one is spent (so that copy can never be replayed onto the cluster port) and the request is an outside client’s: access rules apply, the caller and chain headers are dropped, and aninternalclass answers 403. The forwarded header grants nothing on either port and never did: it only stops the request being forwarded again (a 503 if this node does not own the actor), so a plain node forwarding to a refusing node is served as a client would be. A peer without a cluster address is never used: requests and WebSocket relays to its actors get a 503 andactors.callfails, and its id is logged at most once a minute; a plain base URL is refused at the last check before sending and by the connector itself. A relayed WebSocket upgrade carries the client’s headers minus hop-by-hop ones and every header only a node or the host sets. Refused signatures are logged once a minute per reason. A TLS peer is still never reached in plaintext. The node logs at start that it refuses plain HTTP; the heartbeat is unchanged, so peers cannot see the mode (whether to stop publishing the plain address is open question 4). Refusing nodes still serve outside clients on the public port, including requests a plain node forwards there.) - Revocation tests. (Done:
crates/wasmkeep-server/tests/revocation.rs, two real nodes over the shared bucket, everything between them over TLS. Each test warms up every kind of connection both ways, makesastop trustingb, and times each refusal from the event:b’s heartbeat deleted,bunable to write its heartbeat,b’s heartbeat naming a new key while the old process keeps its connections, oraunable to list the heartbeats. In each,astops sending tob(its pooled connection unused),b’s next request on its kept-alive connection toa’s cluster port gets a 503 and its new handshakes are refused, a WebSocket relayed either way is cut ona’s side, anactors.callin flight either way does not deliver its answer, and the next one fails. Disabling any one mechanism fails at least one test. Measured with a 100 ms heartbeat (horizon 400 ms: ttl of three periods plus 100 ms of skew): requests and handshakes are refused 30 to 110 ms after a deletion or a new key (the next listing), 355 to 400 ms after the last heartbeat write, and 255 to 370 ms after listings start failing (the horizon from the last listing); WebSockets are cut within the next 500 ms check after that, 355 to 400 ms after the event in these runs. Changes: an answer from a peer, or to a peer on the cluster port, is not delivered once that peer’s pin stopped being live: checked when the answer is ready, before every frame of its body, and every 500 ms while the body waits (a forwarded request gets a 503,actors.callfails; on the cluster port a 503 that keeps the connection, whose next request is refused). A node no longer drops its pools when its own heartbeat lapses or its own pin changes (it never connects to its own address), so a node cut off from the bucket keeps its connections to its peers, which refuse them on their side. Still open: a request admitted before the pin lapsed runs to the end (only its answer is withheld), and its body still being uploaded is not cut; revocation of WebSockets and waiting bodies is polled (500 ms).) - Docs and benchmarks. (Done:
crates/wasmkeep-server/benches/cluster.rs, two pairs of nodes over loopback, one plain and one behind cluster listeners whose plain addresses lead nowhere; results inbench/results/2026-11-05-d8e9ab7.csv(and an earlier run,2026-11-05-b07a9d7.csv) and benchmarks. On a pooled connection, TLS adds 9 to 12 µs to a forwarded request (106.3 to 115.1 µs in the later run; 63.2 µs sent to the owner directly) and 15 to 21 µs to anactors.callbetween nodes (133.7 to 155.1 µs), one request at a time. A full handshake is not measured: connections are pooled, and rebuilt only when a pin changes or lapses (forwarding drops hop-by-hop headers, so a client’sConnection: closeno longer closes the connection between nodes). Operator docs were written with each step (docs/cluster.md,docs/security.md,docs/deploy/three-nodes.md,docs/monitoring.md);docs/cluster.mdnow links the cost and the measured revocation times.) refuseby default. (Done 2026-11-06, before any release, so no running cluster changes behaviour on an upgrade:wasmkeep servedefaults to--cluster-plaintext refuse, and a node started with--node-idand without--cluster-listenrefuses to start, naming both ways out (a cluster listener, or--cluster-plaintext accept). A one-node cluster gets a loopback listener (single node). The library’sClusterConfigkeepsAcceptas its default, for embedders and the tests; the CLI is what operators run.)
0012. Garbage collection of actor history in the bucket
- Status: accepted and implemented (2026-11-05: all five steps of the plan below; the open questions are decided below). Revised after an adversarial review of the design and one of each code step.
- Amended by ADR 0020 (2026-11-07): node
leases, ownership records and the
leases/markers are lease state, never collected (see “Not collected here”). - Amended by ADR 0022 (planned, 0.4.0): history collection walks the app’s root,
state/<tenant>/<app>/. - Date: 2026-11-05
- Milestone: M3
Context
Every write an actor commits in a cluster becomes one object in the bucket (a segment), and every 64 segments the owner adds a snapshot (ADR 0007). Nothing is ever deleted: not old segments, not snapshots no restore will start from, not stale uploads that landed after their writer’s lease ran out, and not a deleted actor’s history (a delete only adds a segment resetting the database).
Periodic snapshots bound what a restore replays (fewer than 64 segments), but not what it lists: restore lists the whole prefix, twice when there are segments to download (once since 2026-11-07, ADR 0007). Measured in memory (benchmarks), a restore at 10,000 writes takes 6.1 ms against 2.6 ms at 1,000 while replaying fewer segments; against S3 or R2, which page listings by 1,000 keys, each listing of 10,158 objects is 11 requests. Storage grows the same way, and “deleting an actor” does not erase its data.
ADR 0007 already states the intent: garbage collection deletes segments and snapshots not needed to restore the current head, after a retention window, never on the write path. This ADR decides how, so that it stays safe under the faults ADR 0007 tolerates: uploads that land after their writer’s lease ran out, a new owner that has not uploaded yet, and deletes that land late.
Two facts of the current code shape the rule:
- The planner picks the head from segments first. The head is the
newest segment of the highest generation; a snapshot is the head only
when the prefix has no segment at all (
plan_restore). Walking back from the head, restore stops at the first snapshot on the way. - A periodic snapshot sits at the head’s position. It is taken after an
event, every 64 segments (the code counts segments only; ADR 0007’s “or
64 MB” is not implemented), of the database at the current head
P = (generation, txid), and stored assnapshot/<txid>-<generation>: the same position as the segment that is the head (at 64 segments since the last snapshot the head is always a segment). Its generation is the owner’s only if the owner has uploaded a segment since restoring: a restored replica starts its count at the number of segments after the snapshot its plan started from (since_snapshot = plan.segments.len()), so after restoring 64 or more such segments the next event takes a snapshot even if it wrote nothing, at the previous owner’s head.
Decision
The rule
An owner with lease generation h collects an actor’s history only:
- after a periodic snapshot
Sat positionP = (h, t)that this owner uploaded and that was created and acknowledged by this call: theputreturned success while the lease was valid (ADR 0007’s rule), notExists, notLate; - with
S’s generation equal toh(the snapshot is at a segment this owner wrote, never at the previous owner’s head); - while its lease is valid, checked again before each batch of deletes.
Condition 2 is what makes the rule safe; condition 1 keeps it simple.
Without condition 2 a restore can break (found by the review): m’s
upload of (m, x) is held; j (with m < j < h) restores to
(m, x-1), uploads (j, x) on top of it (also held), and its lease runs
out; (m, x) lands; h restores to (m, x) and, having restored 64
segments, snapshots there without writing anything. Collecting from that
snapshot would delete (m, x-1), and when j’s upload lands it is the
head (its generation is higher than m’s), a legal late commit, whose
parent is gone. With condition 2, S is at a segment h wrote, after
which only uploads built on h’s chain can become the head. Condition 1
is not needed for data: a snapshot that landed late is still the right
database at P, and P itself was acknowledged; at worst an owner that
listed before it landed fails a download and retries. It is kept because
Exists and Late are rare and refusing them costs nothing.
It then deletes, under the actor’s prefix, every object that is:
- a segment or snapshot with txid below
t, of any generation; or - a segment or snapshot of a generation below
h, at any txid;
except S itself and the segment at P. Everything else is kept:
S, the segment at P, and this owner’s segments after t.
The rule is a pure function of key names and the owner’s generation and
snapshot (wasmkeep_cluster::replication::collectable, next to
plan_restore), so the simulation runs the same code as the server.
Why it is safe
The invariant: after any collection, and whatever lands late, the restore
plan of the prefix still exists (plan_restore never returns Missing)
and still contains every acknowledged write (ADR 0007).
- The chain a later owner restores is never touched.
Sand the segment atPwere acknowledged, so they were in the bucket before any later generationkcould acquire, andklists after acquiring. Its head ish’s newest segment (or its own, built on it); walking back it reachesP, whereSis, and stops. Every object on that walk has generationhand txidtor above (or isk’s own): none is collectable. So a delete ofh’s that lands late, afterkrestored, removes nothingkbuilt on. - Late uploads of earlier generations cannot become the head. A
segment of generation
m < hlanding after the collection loses to the segment atP(and anything after it), which has the higher generation. This is whyPis kept: with onlySleft, the prefix would have no segment of generationh, and a late segment ofmwould become the head, with its parent collected (Missing). A late snapshot ofmis never a head while any segment exists, and is off the chain. - No fence is needed. ADR 0009’s fence exists because a new owner that
has uploaded nothing cannot tell whether an earlier owner’s late upload
will become the state. Here collection only follows this owner’s own
acknowledged upload (
S, at a segment it wrote), after which no upload of a lower generation can be the state. - Late uploads of this owner’s own (an upload cut by the event’s wall
clock, landing later) are at txid above
t, generationh: kept.
Deleted actors
A delete ships a reset segment at (h, r) and nothing else, so it would
never trigger a collection. After an acknowledged reset, the owner uploads
a snapshot at (h, r) (the empty database) and collects without the
retention window: erasing the actor’s data is the point of the delete,
and an actor that was deleted gets no later pass. What remains is the
reset segment and that snapshot, two small objects, which keep the actor
known to exist (exists_in_bucket, access mode existing) and restore to
the empty database. The actor’s lease object stays too (a few bytes; not
collected by this ADR). (The doc comment of exists_in_bucket says a
delete leaves an empty snapshot; it leaves a reset segment, corrected with
the server step.)
When it runs
- One collection pass per actor, in the background, after a periodic or delete snapshot that qualifies under the rule: never inside an event, never delaying an acknowledgement. At most one pass per actor at a time: a pass requested while one runs (a periodic snapshot, then the delete in the same event) runs after it, never skipped. A pass in flight when the actor is given up stops before its next batch.
- A pass lists the prefix once, computes the collectable keys (a pure
function of key names,
collectable), and deletes them in batches (delete_stream, a bulk request on S3), checking the lease before each batch. Objects younger than the retention window (theirlast_modifiedin the listing) are kept for a later pass (except after a delete). - Cost: one listing per 64 writes, and one delete per object ever written (once). A listing covers the whole prefix, about one retention window of writes for a busy actor, so passes do not queue up: while one runs, a single request waits, replaced by any newer one (its snapshot is newer, so it collects at least as much). After a delete, a failed pass is retried a few times while the node holds the actor. (From the review of step 3.) Failures are logged and counted; the next qualifying snapshot tries again.
Deploy artifacts (if in scope, see open questions)
apps/<tenant>/<app>/<version>/ holds each deployed version’s components
and manifest, linked by previous. Keep the current version and the
versions its manifest chain reaches within N steps (rollback walks that
chain), delete older versions after the retention window. Nodes load only
the version current names, so a node that has not caught up yet is not
affected. Run by wasmkeep deploy after it replaced current, not by
nodes.
Not collected here
Heartbeats of retired nodes (nodes/<id>), leases of deleted actors, and
alarm index entries (ADR 0009 removes those itself) are small and bounded
by the number of nodes and actors; they are left for later.
Amended by ADR 0020 (2026-11-07): node
leases (nodes/<id>, format 3), ownership records (owners/...) and the
markers that replace per-actor leases (leases/..., wasmkeep-lease 2)
are lease state, never collected. A record may name a node long after it
stopped, and only that node’s object, fenced or released, frees it; a
marker is what keeps a binary from before ADR 0020 from taking the actor.
wasmkeep cluster forget removes only a retired node’s heartbeat in the
format from before node leases.
Plan
One PR each, with tests that fail before it:
- This ADR; ROADMAP and
docs/cluster.md(“Not yet”) link it. (Done.) - (Done:
collectablewith seven unit tests, including the review’s interleaving for condition 2, scripted. The simulation models snapshot contents and snapshots right after a restore, and owners collect by the rule with deletes that land late: over 200 seeds, about 4,500 acknowledged writes, all restored, and 5,800 objects collected. Deleting the segment atPis caught at seed 28. Dropping the generation guard is not reached by the simulation within 5,000 seeds, even with periodic snapshots made to fail and owners pausing after a restore: it needs two held uploads and a snapshot right after a restore in one narrow window. The unit test scripts it instead, restore failing withMissingwithout the guard.) Planned as:collectable(listing, h, S)inwasmkeep-clusterwith unit tests (the kept set, earlier generations at any txid, a late segment of an earlier generation after collection, the guards, a delete’s reset). The replication simulation (sim/tests/madsim_replication.rs) gains owners that collect by the rule through a delete client whose requests can land late. Before that, the simulation must model what it currently does not: snapshot contents (each snapshot records the positions it holds, so an acknowledged write below a snapshot counts as restored when it is in the plan’s snapshot), and a final check that re-plans with the snapshots instead of walking every chain back to the empty database; and snapshots taken right after a restore, at the restored head, as the server does once 64 segments were restored. Unsafe variants some seed must catch: keeping onlyS(not the segment atP), and collecting without the generation guard. (Collecting after a late snapshot is not unsafe, see condition 1, so it is not a variant.) - (Done:
Collectorinreplica.rs, triggered from the periodic snapshot and after a delete;--history-retention-secs, default 3600;wasmkeep_history_collected_totaland_collect_failures_total; three cluster tests, plain and over TLS, the retention one failing when the window is ignored. The flag is named--history-retention-secs, like the CLI’s other durations. Not covered by a dedicated test: a pass stopping when the lease is lost.) Planned as: the server pass:ship_snapshotreports whether it created the snapshot; collection after a qualifying snapshot, the retention flag (--history-retention, 1 h), a metric for objects collected and passes failed, delete snapshots collected without the window, passes queued per actor. Cluster tests: the prefix shrinks toS, the segment atPand what follows; a restore on another node after a collection; a deleted actor leaves two objects; nothing is collected while the lease is lost. - Deploy artifacts. (Done:
AppRef::collect_versionsincrates/wasmkeep-server/src/apps/collect.rs, run bywasmkeep deployafter it changedcurrent, with--version-retention-secs(default 3600). The current version andKEEP_PREVIOUS(5) before it are kept; only the current version’s lineage is a candidate, so a directory a concurrent deploy is still writing, or a failed deploy left, is never touched, nor the versions a rollback stepped back from (rollback re-pointscurrentat an existing version): those leak, a few small objects each. Manifest first, candidates oldest first (a pass cut short strands nothing reachable),currentre-read before each version, the window counted from when a version stopped being current (its successor’s manifest), only plain ids followed, and an unreadable manifest never deleted (from the review of step 4). Unit tests and a cluster test with a node following the app.) benches/restore.rsrerun at 10,000 writes after collection; the benchmarks page updated. (Done:restore/collected/Nandlist/collected/N, recorded inbench/results/2026-11-05-4c8b21a.csv: at 10,000 writes a restore takes 1.23 ms after collection against 6.13 ms before, and the listing 1.7 µs against 1.72 ms; the collected prefix holds at most 65 objects. Benchmarks.)
Decisions on the open questions (2026-11-05)
- Retention window: 1 hour, configurable (
--history-retention-secs, 0 allowed), except after a delete (see “Deleted actors”). - Deploy artifacts: in this ADR (step 4 of the plan).
- Idle actors: acceptable for now. Collection follows writes, so an actor that stops writing keeps its last interval of history (fewer than 64 segments) plus whatever was younger than the window at its last pass, until it writes again.
What can still go wrong
- An actor whose periodic snapshots keep failing is never collected: its prefix grows as today (the failure is logged; the history-length benchmark measured that case without snapshots).
- Collection deletes history: a bug in the rule loses data. Hence a pure rule shared with the simulation, the retention window, and a metric.
- Clock skew beyond the bound breaks the acknowledgement rule, and with it this one, exactly as it breaks leases (ADR 0007).
- Not this ADR’s to fix, but met on the way: because the planner prefers
segments, a prefix whose only object of the highest generation is a
snapshot can have its head displaced by a late segment of a lower
generation. Collection avoids it by keeping the segment at
P. An actor’s first snapshot (uploaded when the bucket had nothing for it) has no segment under it, but the only earlier uploads that can still land there are other first snapshots (an owner uploads segments only after its first snapshot was acknowledged), and a snapshot of a higher generation outranks them. - Restore lists the prefix once (ADR 0007; twice before 2026-11-07); collection bounds the size of that listing, not its existence.
0013. Access rules and plain configuration travel with deploys
- Status: accepted and implemented (2026-11-05; steps 2 to 4 of the plan, tests in
apps.rs,tests/deploys.rs,tests/cluster/, the CLI’s unit tests andcrates/wasmkeep-cli/tests/deploy_config.rs, the S3 one run against MinIO). Revised after an adversarial review the same day (old deploy tools over new versions, reused version ids, rollback across the migration, the class an event takes, sockets, instances kept with old values). - Date: 2026-11-05
- Milestone: M3
Context
Two things a deployed app needs are still given to each node on its
command line, not by wasmkeep deploy
(ADR 0008):
- Access rules,
--access CLASS=MODE(ADR 0010). ADR 0010 says “every node of a cluster must get the same rules”, and nothing checks it. A node started without--access monitor=existingservesmonitorasopen, and every request reaching it can create an actor, a lease and a snapshot in the bucket: the hole ADR 0010 closed, reopened by one forgotten flag. - Plain configuration,
--env CLASS=FILE:NAME=valuefiles the actors read withenv.get. The same files also hold secrets.
An earlier design sketch left one question open: is configuration part
of the deploy version? It proposed a separate pointer for configuration,
“so changing a URL neither reruns on-init nor rolls back with code”.
The first half of that reason does not hold. on-init runs once per
component, not per deploy: ActorClass::version is the SHA-256 of the
component’s bytes, prepare compares the actor’s recorded init version
with it, and apply_deploy keeps the resident instances of a class whose
bytes did not change. A deploy that changes only rules or configuration
does not rerun on-init, wherever they are stored.
Secrets are out of scope: storing them in the bucket needs encryption and
key handling (since added, see
ADR 0016). They stay in per-node --env
files, and no secret value may reach the bucket.
What the code did before this ADR that matters here:
- The front door decided access before the event took its class
(
refresh_class, insideprepare), withensure_owner(bucket I/O) in between. A deploy landing in that window ran the new class under the old decision. - An actor’s mode was captured once, when its mailbox was created, and its values once, when its instance was created. A resident instance kept across a deploy kept both.
- A version id was the hash of the class table, and its directory, with
previousin its manifest, was reused when the same table was deployed again later, sopreviouscould point at an older version than the one actually replaced.
Decision
Rules and configuration are part of the version
A version is its classes, their access rules and their plain
configuration. One pointer (current), one sequence, one follower, one
atomic swap, as in ADR 0008.
- Rules must change atomically with the code they protect, in both
directions. Code written for
openmay check names itself; code written forexistingmay rely on the platform to. Running either under the other’s rule is a hole. - Configuration usually changes with code too (new code reads a new
URL), and the sequence check of ADR 0008 then gives “no old configuration on newer data” for actors that already ran the new deploy: a node still on deploy 6 does not run an actor that ran deploy 7, with its code or its values. - A separate pointer would need a second sequence, a second follower and a rule for which of the two an event records. Its only remaining benefit is that a rollback of code keeps the newer configuration; see Rollback for why rolling it back is the safer default.
The cost is that a configuration-only deploy is a new version, whose
directory needs the components. wasmkeep deploy copies them inside the
bucket from the current version (ObjectStore::copy, a server-side copy on
S3 and R2), so nothing is uploaded again. The copy is unconditional, which
is safe because the destination key is content-addressed: an overwrite
writes the same bytes. Nodes check every component’s digest when they load
a version, so a corrupt source is refused there, not run.
Bucket layout
apps/<tenant>/<app>/current "<version> <seq> [2]"; If-Match only
apps/<tenant>/<app>/<version>/manifest immutable
apps/<tenant>/<app>/<version>/<class>.wasm immutable (unchanged)
apps/<tenant>/<app>/<version>/<class>.config immutable, new: one per class with configuration
No new pointer and no new prefix. A version directory holds everything a node needs to run that version, and nothing another version refers to.
Version ids
The id is the first 16 hex digits of the SHA-256 of the manifest’s
identity, followed by its previous line:
- the identity is the
classlines, then theaccesslines, then theconfiglines, each group sorted by class; previous <version>\nis appended when there is one.
Including previous means a directory is never reused under a different
lineage: deploying the same classes again after something else gets a new
directory, whose previous is the version it actually replaced, so
rollback and garbage collection follow the real history. A deploy is a
no-op when the identity equals the current manifest’s (compared on the
text, not the id), so redeploying what is current still changes nothing.
Directories written before this ADR keep their ids; only new ones are
named this way. (ADR 0008’s “the version hashes the class table” is
replaced.)
For bucket garbage collection (ADR 0012,
step 4 of its plan): a version is deleted by deleting everything under <version>/,
not a list of known names, and the manifest first, so a half-deleted
directory has no manifest and no node loads it. No version’s objects are
shared with another. A directory is written again only by a deploy of the
same identity onto the same previous; such a deploy recreates the
objects that are missing (create-only: existing ones are not rewritten)
and reads the manifest back before it swaps current. Collection
therefore re-reads current before each version it deletes, and stops
if it changed.
Manifest
A manifest without rules or configuration is written exactly as before
(wasmkeep-manifest 1). One with either is wasmkeep-manifest 2:
wasmkeep-manifest 2
previous <version>
class <class> <sha256 of the component>
access <class> <spec>
config <class> <sha256 of the .config object>
accesslines: one per class whose mode is notopen.<spec>is the rest of the line after the class and one space:existing,internal, orexisting:NAME,NAME(names may contain spaces,room 1; class names cannot). Names are sorted and deduplicated; a line not in that canonical form is refused. A rule for a class not in the version is refused, bydeployand by the parser.configlines: one per class with configuration. The.configobject holds the parsed values, not the file: oneNAME='value'line per name, sorted (env::parsereads it back unchanged, since nothing inside the quotes is interpreted). Comments and commented-out lines are never uploaded, and reformatting a file does not make a new version. At mostenv::MAX_FILEbytes.- The parser refuses any line it does not know, as before. A node reading a manifest it cannot parse keeps its version, whole (below).
Values never appear in the manifest, only digests. Parse errors name the
object and the line, never a value (the env.rs convention).
The format marker in current
Once a version with rules or configuration has been current, every later
write of current carries a third field, 2 ("<version> <seq> 2"),
rollbacks included. Before this ADR, current was read as exactly two
fields, so tools and nodes from before refuse it as unreadable:
- an old
wasmkeep deploy(a CI image not upgraded) fails instead of writing a format-1 version on top, which would drop every rule; - an old node keeps its version (or, starting, does not start).
The marker also tells a new node whether rules have ever been deployed for the app (Node flags).
wasmkeep deploy
wasmkeep deploy --app T/A --bucket B [--actor CLASS=PATH]... \
[--access CLASS=MODE]... [--config CLASS=FILE]...
-
A deploy changes what it names and keeps the rest. Starting from the current version:
--actorgiven replaces the class table, as before (classes left out are removed, with their rules and configuration);--actorabsent keeps the current classes.--accesssets a class’s rule (CLASS=openremoves it).--config CLASS=FILEreplaces a class’s configuration (CLASS=removes it). A class that is not named keeps its rule and configuration.A purely declarative deploy would move the hole from node flags to deploy flags: a CI job running
wasmkeep deploy --actor ...without the--accessit never knew about would reopen every class. Keeping what is not named makes the safe outcome the default, and the manifest still records the whole version. The base is the manifest of thecurrentread whose ETag the swap is conditioned on, so a concurrent deploy fails with a conflict rather than losing a rule. -
--accessand--configfor a class not in the new version are refused (a typo would otherwise leave the class open or unconfigured). Names with control characters are refused. -
A deploy that removes a class with a rule other than
opensays so; if it also adds a class (a rename), it warns that the new class is open unless given a rule. -
--configis stored in the clear. The flag is not called--envso that a node’s secrets file is not passed by habit. A value that looks like a secret is warned about, naming the class and the variable, never the value: a name containingSECRET,TOKEN,PASSWORD,PASS,KEY,CREDENTIAL,AUTHorDSN(any case), or a URL with a password in it (a user and password before an@). The deploy goes on: the warning is a nudge, the documentation the rule. -
The deploy prints what changes against the current version: classes, rules (one line per class whose mode changes) and the names of configuration values added, changed or removed.
-
Upload and switch as in ADR 0008: components (or server-side copies),
.configobjects and the manifest with create-only writes, thencurrentwithIf-Match.
Rollback
wasmkeep deploy --rollback re-points current to the current manifest’s
previous, as before, with the rules and configuration of that version,
and prints the same diff.
A rollback that would make any class more open (existing to open,
internal to anything else, an existing: list that grows) is refused
unless --allow-reopen is given. The cases it catches: a deploy that only
hardened a rule, then a rollback that would undo it; and a rollback past
the migration point, to a version from before rules were deployed, which
would open every restricted class. Keeping the newer rules on rolled-back
code instead is not safer in general (code written for existing rolled
back under open has nothing checking names), so the version’s rules win,
but only on purpose. An explicit --access CLASS=open in a deploy is
already on purpose and needs no flag.
Nodes
A node following an app reads the version, its rules and configuration
(digests checked), compiles the classes, and swaps everything in at once
with the sequence (apply_version). A version the node cannot read or
build (an unknown format, a digest mismatch, a malformed .config) is
refused whole: the node keeps the previous version, with its rules and
values, logs the error and backs off, as in ADR 0008. While it is behind
for that reason (not for a bucket outage, which every node shares), it
reports not ready, so a load balancer stops sending it requests it would
decide under rules the app no longer has. (ADR 0008 also says the node
reports the error in its heartbeat; that is not implemented, and belongs
with wasmkeep cluster status.)
Rules and values belong to the class an event takes, and an event takes
one. ActorClass carries its version’s rules and its merged values.
Each event (a request, a call, an alarm, a socket open or message):
- takes the class once, at its start (
admit, orrefresh_classfor an alarm); nothing later in the event takes another, soprepareruns on it even if a deploy lands while the event waits for the bucket or a lease; - decides access with that class’s rules: an outside request to an
internalclass is refused (403); to anexistingclass it reaches only an actor that exists (404 otherwise); - runs on that class, with
env.getreading its values.
The front door keeps its check only as an early refusal (it saves a forward and a mailbox): it may refuse with the rules it sees, but admitting is decided in the event. A refusal is never a hole; an early admission under a rule the event’s class does not have would be.
- Sockets. A WebSocket message or close is an outside event like any
other. Under
existingthe actor must still exist (as before); under a class a deploy madeinternal, the socket is closed (1008) at its next event. - Values on resident instances. A deploy that changes a class’s values
but not its bytes drops the class’s resident instances at their next
event: the database is kept and
on-initdoes not rerun (it is keyed on the component), but a guest that cached a value in memory or derived state from it reads the new values, the same on every actor of the class. A deploy that changes neither (rules only, or the same bytes redeployed) keeps them, as before. - Logs. Each applied version logs every class’s effective mode, as
--accessdid at start.ActorEnv,Versionand the deployed configuration inActorConfigprint names and counts inDebug, never values.
Node flags
-
--accesswith--appis accepted only while no version with rules has ever been current (no marker incurrent), and only so that a cluster can be upgraded without changing its command lines first (the steps below). It is then ignored as soon as a version with rules is applied, with a warning to remove it, and it is refused at start once the marker is there. The error says what to run instead.Letting the stricter rule win (the earlier sketch’s proposal) would keep per-node rules, and with them answers that depend on the node: a request is checked where it arrives and checked again on the actor’s owner (ADR 0010), so the outcome would depend on which two nodes are involved, which is indistinguishable from a bug. It also needs intersecting
existing:name lists. And a node flag is a poor break-glass: a rules-only deploy reaches every node in about one heartbeat (3 s), restarting nodes takes longer. Without--app(--actorclasses on one node, orwasmkeep dev)--accessworks as before: there is no deploy to carry it. -
A node with
--app, no--accessand no marker logs a warning at start that the app has never deployed access rules, so every class is open: the case of an operator who dropped the flags without deploying the rules. -
--envstays, and wins by name. Secrets lived only there until ADR 0016 put them in the bucket, sealed. Each class’s values are the deployed configuration with the node’s--envvalues over it. A name set by both is logged once per version applied, as a warning naming the class and the variable, never a value. Refusing the conflict instead would block a later deploy on that node. -
--envfor a class not served is refused at start without--app, as before. With--appit is a warning: a deploy that removed the class must not keep nodes from restarting, and the values reach the class if a later deploy brings it (back).
Migration and mixed versions
A deploy with neither rules nor configuration stays format 1 with no
marker, and nodes from before this ADR take it as before. Everything else
is refused whole by old nodes and old tools: they cannot parse
wasmkeep-manifest 2 or a current with the marker. A running old node
keeps its version and refuses (503) actors that already ran the new
deploy on an upgraded node, as ADR 0008 does for any version a node cannot
build. An old node that restarts cannot start. Both fail closed. Hence the
order, for a cluster started with --access:
- Upgrade every node to the new binary, command lines unchanged. The
deployed version has no rules yet, so each node keeps applying its
--access, as before. - Deploy the rules (and configuration, if wanted) once, without
--actor:wasmkeep deploy --app T/A --bucket B --access monitor=existing. Every node switches to the deployed rules at the same sequence, and warns that its--accessis now ignored. - Remove
--accessfrom the command lines (a node restarted with it is refused from now on).
The marker is set by the first version with rules or configuration, and
from then on nodes ignore --access. A first deploy that carries only
configuration would therefore drop the flags’ rules. The deploy that sets
the marker says so, and lists the rules nodes will apply from then on
(every other class open); step 2 deploys the rules with it.
Upgrading WasmKeep itself is “every node together” in M3
(ROADMAP);
step 1 is that. Between steps 1 and 2, as before this ADR, a node started
without --access serves every class open, and says so in its log.
Alternatives
- Configuration under its own pointer (the earlier sketch’s proposal). Rejected above:
on-initis per component, so its main reason is gone, and two sequences would need their own ordering rule. - A declarative deploy (the flags given are the whole version). Rejected: forgetting a flag in CI reopens a class.
- Stricter rule wins between flag and manifest. Rejected: per-node answers, merging name lists, slower than a rules-only deploy.
- Lineage outside the version directory (
previousincurrent, or a history object per sequence) instead ofpreviousin the id. A history object written before the swap can be orphaned by a crash and then block the next deploy at that sequence;previousincurrentalone cannot walk back more than one step. Hashingpreviouskeeps the manifest as the only record and costs a new directory per change, which happens anyway when the content changes. - Content-addressed components (
apps/<t>/<a>/blobs/<sha256>.wasm, shared by versions) instead of copies. It saves storage, but versions would share objects, and garbage collection would need reference counting. Server-side copies keep each version self-contained.
Consequences
- One forgotten flag no longer reopens a class: a node with no
--accessserves the deployed rules. - Rules and configuration change on every node in about one heartbeat, with no restart, and never apart from the code they were deployed with.
- The window is not zero. Until a node applies a version that hardens
a rule, it decides under the rule it has: a new name of a class just made
existingcan still be created through it, and then exists. For following nodes that is about one heartbeat; a node that cannot apply the version reports not ready instead of deciding for minutes. To close a class at once with no window, there is no substitute for stopping traffic to it upstream. - A configuration-only deploy is a version: it gets a sequence, a line in
the
previouschain, and a server-side copy of each component. Rollback goes back one change, whatever kind it was. serve --appwith--accessis refused once rules are deployed: the one breaking change, pre-release, with the replacement in the error.- Configuration is readable by whoever reads the bucket (already trusted with everything: security). Secrets stay on nodes.
Tests
Each fails before its change; the ones marked mutation-checked are run again with the guarding line removed.
- Manifest (unit,
apps.rs): formats 1 and 2 round trip; a manifest without rules or configuration is written byte for byte as before; names with spaces round trip, and non-canonical lines, unknown lines, rules for classes not in the version and format-1 manifests with rules are refused;previousis part of the id;.configobjects are canonical (comments dropped, quotes and spaces kept, reformatting changes nothing);currentwith and without the marker. - Lineage (
tests/cluster/): deploy X, then Y, then X again: the third is a new directory whosepreviousis Y, and a rollback goes to Y. - The class an event takes (
tests/deploys.rsandtests/cluster/, mutation-checked):- request B is admitted at the front door under
openand queues behind A (held at theopenfault point); a deploy making the classinternallands; B is refused 403 (mutation: let the front door decide); - request B to an existing actor of an
existingclass waits in its existence check (a bucket whose listing is held); a deploy changing the code and making the classinternallands; B runs on the code it took (mutation: take the class again inprepare).
- request B is admitted at the front door under
- Sockets: an open socket of a class a deploy makes
internalis closed with 1008 at its next message. - Configuration (
tests/deploys.rs): a configuration-only deploy changes whatenv.getreturns, without rerunningon-init, and drops the instance (mutation: keep it); a node--envvalue wins by name. - Cluster (
tests/cluster/): two nodes started on a version with rules and no flags: an unknown name of anexistingclass answers 404 through either node and leaves nothing in the bucket; an actor created by a call is reachable through both; configuration reachesenv.geton both; a rules-and-values deploy reaches both, after which outside requests are refused and calls still reach the actor. - Readiness: a node that read a version it cannot apply reports not ready; a bucket outage does not.
- CLI (
crates/wasmkeep-cli/tests/):serve --appstarting on a format-2 version;serve --app --accessaccepted without the marker (and the flag ignored after a version with rules), refused with it;deploy --accessand--configfor a class not in the version refused; a deploy without--accesskeeps the current rules; a configuration-only deploy copies the components;--rollbackthat reopens refused without--allow-reopen, and printsexisting -> open; two concurrent deploys, one with--access, one with--actor, end with one conflict and no lost rule; a secret-looking value is warned about, and no value appears in the output or the logs.
Plan
- This ADR.
- Manifest format 2, canonical
.configobjects,previousin the id, the marker incurrent,AppRefreading and writing them (S). - Rules and values carried by
ActorClass; one take per event;apply_version; sockets; dropping instances on new values; readiness; the race, configuration and cluster tests (M). - The CLI:
deploy --access/--config, keeping what is not named, server-side copies, diffs and warnings,--rollback --allow-reopen,serve --appwith or without--accessand the marker; docs (docs/cluster.md,docs/security.md, ADR 0010’s cross-reference) (S).
0014. Draining a node and moving an actor
- Status: accepted and implemented (2026-11-05;
actor movebuilt as ADR 0017). Revised the same day after an adversarial review: peers of another tenant, clusters without a cluster port, the five places a node claims, the hand-over racing its own release, alarms and sockets during a hand-over, and the cost of the control listing. The first draft’s “warm-up” became an alarm hand-off. - Amended by ADR 0020 (2026-11-07): ownership is a node lease plus an
ownership record per actor; where this record says lease, read the
actor’s hold (
LeaseView: the hold exists and the node lease is valid). - Amended 2026-11-07, from the drain simulation on node leases: a peer
that accepted an actor’s alarm from a hand-off looks again after 1 s
(for 30 s, or twice ttl plus grace if longer) while the actor’s record
names another node, whatever its
control/listing says. A draining node whose hold a bucket outage ended hands the alarm off with its release still pending; a peer that had not listed the drain yet left the alarm to it, and nobody fired it until the sweep (a_handed_alarm_waits_for_the_senders_release). - Amended by ADR 0022 (planned, 0.4.0): move keys move under the app’s root, and node-to-node calls carry the app.
- Date: 2026-11-05
- Milestone: M4
Context
An operator who replaces a machine, resizes it or adds a node has one tool today: stop the process. A clean stop hands actors over at once, but the node is gone meanwhile. An idle actor’s alarm then waits for the sweep: about 25 s after it is due, with the defaults (ADR 0009). There is no notion of a node that is up but should not take actors.
What the code does today, and what this ADR has to work with:
-
Routing. A node that has no mailbox for an actor asks for its owner: the owner hint (1 s), then the lease. A lease that is missing, released (
expires_at 0) or expired past grace counts as free. A node claims inensure_owner, which has five callers:- HTTP requests and
actors.call; - the upgrade probe;
- alarm delivery;
ws_open;- socket events.
acquirealso lets a node take back its own unexpired lease. A request is forwarded at most once (x-wasmkeep-forwarded, or arrival on the cluster port). A receiver that finds another owner answers 503 withRetry-After: 1. - HTTP requests and
-
Tenants. Nodes of different tenants may share a bucket prefix. A node serves every
/actor/…path as its own tenant, so it must never be sent another tenant’s actor. Today forwarding only targets the holder of the actor’s own lease key, so that never happens. -
Release. Dropping an actor’s state drops its
LeaseHold, which spawns the release PUT. Only shutdown waits for it, through a node-wide counter. A failed upload abandons the actor, which also releases.Mailboxes::hibernatereturns nothing to wait on, and an actor with queued events stays resident. The mailbox registry forgets an actor before its state, and so its lease, is dropped. -
Last write. Each event ends in
finish, which awaits the upload of its changes before the next event or hibernation. A release follows the last upload, except an upload cut short by the wall-clock limit, which may still land later as a late commit (ADR 0007). -
Alarms. A node arms an actor’s alarm only in these cases:
- when an event on it finishes;
- when the alarm event runs;
- on a retry or a sweep;
- by scanning its local databases at startup.
Acquiring and restoring arm nothing. After a release, the old owner’s timer still holds the alarm. When it fires, the old owner skips it if another node owns the actor, and otherwise claims it. A refused alarm event is retried only on
CapacityorFull; any other error completes it. The alarm path (alarm(hint)) acquires, restores, re-arms a future alarm, runs a due one, and clears stale index entries. -
Signed calls. The only signed node-to-node call is
actors.call. It is honoured on either port in ADR 0011’s phase A, and only on the cluster port in phase B. A cluster may have no cluster port at all. -
Heartbeat.
nodes/<id>, headerwasmkeep-node 2, six lines, then the cluster address and pins (lines 7 and 8) only with TLS. A reader takes at most 8 lines and drops a malformed or empty line 7 or 8. -
Listing.
Directory::listlists the prefix, then reads every object’s body in sequence. One failed read fails the whole listing.
Decision
Intents are object keys in the bucket
As for deploys (ADR 0008), the operator writes the bucket and nodes poll it. No admin API. The intent is in the key; bodies are empty and never read.
control/drain/<node-id>
control/move/<tenant>/<class>/<name hex>/<target node-id>
Not under nodes/<id>/: on MinIO, an object and a “folder” of the same
name conflict. Each node lists control/ once per heartbeat period (3 s),
reading keys and their last-modified time only.
wasmkeep cluster drain <node-id>writes the drain marker.wasmkeep cluster undrain <node-id>deletes it.wasmkeep actor move <tenant>/<class>/<name> --to <node-id>writes a move key. A new target is a new key, so deleting one move never removes another.- A move key whose last-modified time (the bucket’s clock) is more than 5 minutes old is ignored, and the node that lists it deletes it.
- A failed listing changes nothing. A node keeps the drain state it last read until a successful listing shows the marker gone. A failure therefore never undrains every node at once.
Who may receive an actor
An eligible peer, for actor A on node D, is a live node that meets all of these:
- serves the same app as D (heartbeat line 6 equal to D’s), so the same tenant;
- writes heartbeat line 9 (below), which shows it knows this ADR;
- is reachable from D the way
actors.callreaches it: through its cluster address in phase B, through either address in phase A; - has no drain marker in D’s last listing.
Same app means the same tenant. For nodes serving classes given on the
command line (tenant/), it does not guarantee the same classes; a peer
without the class answers 404, as any node would.
A peer that fails to take an event sent to it is suspect for a heartbeat horizon (10 s by default) and not chosen meanwhile. Failing means a 5xx answer, a refused connection, a failed call, or a 503 from a peer that is draining itself. By the time it is chosen again, a dead node has left the peer list and a draining one shows it on line 9. The event that failed gets its error; the next ones go to another peer, or are claimed here. A chosen peer is never recorded as the actor’s owner, because it may not become one.
The chosen peer for A is the eligible peer with the highest rendezvous hash of (A’s address, peer id). Every draining node picks the same peer for the same actor. The choice spreads actors evenly, and it needs no load figure, so it cannot herd every hand-over onto a newly added node.
Heartbeat line 9
Lines 7 and 8 keep their meaning and are written empty without TLS, so
line 9 is line 9 on every node. Readers today drop an empty line 7 or 8
and read at most 8 lines, so older nodes see no change. The header stays
wasmkeep-node 2.
line 9: serving | draining | drained | stuck
Nodes learn about drains from control/, which is fresher; line 9 is
for eligibility and cluster status. stuck means draining with actors
left and no eligible peer.
A draining node stops claiming
The check sits in ensure_owner, so it covers all five callers. On a
draining node, ensure_owner never takes a lease that is free, and
never takes back its own lease once that is no longer held in memory.
It returns a new outcome, Elsewhere(peer), which each caller handles
as below:
| Caller | On Elsewhere(peer) |
|---|---|
| HTTP request, not yet forwarded | forward to the peer; the peer claims |
| HTTP request already forwarded, or arrived on the cluster port | 503 with Retry-After: 1 (the sender’s hint lasts at most 1 s) |
actors.call from a local guest | call the peer instead |
| Upgrade probe | forward_upgrade to the peer, or 503 with Retry-After: 1 if already forwarded |
| Alarm delivery | alarm hand-off to the peer (below); the timer retries if it fails |
ws_open, socket events | none in practice: sockets are on the owner and close before a hand-over |
With no eligible peer, a draining node claims as today and reports
stuck. Draining must never make an actor unreachable.
Other nodes need no change. They forward to the draining node only for actors it still owns. An older node may also forward an actor whose owner hint has gone stale; it gets the 503 above, for at most 1 s.
Handing over one actor
A draining node hands over its resident actors, up to 16 at a time (a constant, not a flag), once per heartbeat period while it drains. For each actor:
-
Close its sockets with 1012 (“actor moving; reconnect”) and wait, up to 5 s, until their
on-websocket-closeevents have run, each with its upload. They run on this node, while it still holds the lease. A close event queued after the release would find no lease and never run. Upgrades arriving meanwhile go throughensure_owner, as on any node. The job checks again when it runs: a socket still open or closing (accepted since, or a client slow to answer the close) makes it keep the lease, close the socket and leave the actor for the next pass. -
Queue a hand-over job in the actor’s own mailbox. It runs after every event already queued, each of which finished with its upload. It checks again that the node still drains and has a peer (a job that runs late does nothing), reads the committed alarm, closes the instance and the database, and releases the lease, waiting until the release is written.
releasereports one of three outcomes:Released;Superseded: the lease was no longer this grant’s (lost, taken over, or already released);Failed: the bucket kept refusing, retried every 250 ms up to the lease’s TTL. The lease then expires on its own (ttl + grace), and requests meanwhile get 503.
A lease that had already lapsed in memory is dropped (released as on hibernation), with nothing to hand over.
-
Drop the actor from memory. Events queued behind the job, and any later ones, find no lease.
ensure_ownersends them to the chosen peer (a request is forwarded, a call goes there, a socket closes), which claims. Nothing on this node can take the lease back: a draining node never acquires a lease it does not hold in memory. -
Hand off its alarm, if it has one. The alarm goes to the chosen peer on a signed node call, the same call
actors.callmakes. It carries a marker header (x-wasmkeep-alarm-handoff), and the due time is in the signed body, so it cannot be altered in transit. It followsactors.call’s port rule (ADR 0011), signature and single use. The peer arms its timer with the time unless an earlier one is registered, and from there the existing alarm path does all the work: acquire, restore, run the alarm if still committed, clear stale index entries. The peer also forgets any owner it had learned for the actor. When the peer confirms, this node forgets the alarm, unless a newer time was registered meanwhile. If the hand-off fails, the alarm stays registered here and is retried each pass. If the release failed, the alarm stays here until the lease expires. An actor with no alarm needs no hand-off: its next request restores it wherever it lands.
If the events queued before the job have not finished within 30 s (a long event, or a hot actor), the drain moves on and tries that actor again on a later pass. The job, still queued, runs when its turn comes.
At the peer, the alarm hand-off:
- is honoured only on a verified signed call whose signature covers the marker, so it cannot be added or stripped in transit. The marker is stripped from every client request and every guest’s call, so neither can arm another actor’s alarm;
- answers 403 for another tenant (the body names it), and 404 for a class the node does not serve, like any call;
- answers 409 while the node is draining itself;
- answers 200 once the alarm is armed. It neither acquires the lease nor restores the database.
An older peer is never eligible, and treats the call as an ordinary one.
When an alarm fires on a draining node. It goes through
ensure_owner, which hands the alarm off to the chosen peer. On a
refusal or an error, the node suspects that peer and tries again in
500 ms, with another peer or, with none left, by claiming the actor and
running the alarm itself. As a last resort, the sweep still delivers the
alarm (ADR 0009).
Why releasing first is safe: release then acquire is what hibernation and shutdown already do, so no lease or replication rule changes. The hand-off only arms a timer: the peer acquires through the alarm path, which runs every check it runs today.
The first version of this section described a leaving state in the
mailbox registry and a /_wasmkeep/alarm path. Both were replaced while
building it:
- The job. Running in the actor’s mailbox, it orders itself after the queued events without a new registry state, and it removes the race between dropping the state and the release starting.
- The marker. Carried on the signed call, it reuses that call’s signing, replay protection and port rule, instead of adding a new path to both listeners.
The alarm sweep
A node that drains leaves the sweep’s shard list, on every node that reads its drain marker or its line 9. The others divide its share of overdue entries between them. Its own sweep still delivers entries that are anyone’s (3x overdue), through the alarm hand-off. If no other node is left, it keeps its share, so no entry is nobody’s.
The shard list also leaves out live nodes of another app on the same bucket prefix (another tenant). They never deliver this app’s entries, so their share used to wait until it was anyone’s. An older node that publishes no app line still counts.
Moving one actor
Not built by this ADR. The owner can release an actor, but nothing then makes
the named target take it before the next request claims it wherever that
request lands. Moving to a target needs the target to acquire first (a
warm-up), which this ADR set aside. Under this ADR, wasmkeep actor move
only wrote its request, and nodes read it and did nothing with it. Built
as ADR 0017.
When a drain ends
When a draining node holds no actors (routing stubs do not count), it
writes drained on line 9. It keeps running until the operator stops it.
It still answers requests by forwarding them, so a load balancer can be
updated afterwards. undrain returns it to serving.
Alternatives
- Drain by stopping the process (today). It is simple, but it moves every actor at once, and idle actors’ alarms wait for the sweep.
- A warm-up that acquires and restores on the peer (this ADR’s first draft). It arms an alarm without running the alarm path, so a stale index entry is never cleared: the sweep re-sends it every 10 s. It also adds a lease protocol (200/409) for no gain in correctness. Rejected for the alarm hand-off.
- Choosing the peer with the fewest resident actors. Heartbeats are
up to 3 s old, so every hand-over in that window goes to the same
peer: a newly added node takes everything and hits
max_resident. Rejected for rendezvous hashing. - An admin HTTP API on each node. It needs its own authentication, and it reaches one node, whereas the bucket reaches all of them. Rejected for now, like ADR 0008’s “push to nodes”.
- A hash-preferred home node per actor (an earlier design sketch). Not
needed: hibernation already spreads load. Revisit if
wasmkeep_forwards_totalshows forwarding dominates. - Pinning an actor to a node. It would need lease semantics beyond “first to claim”.
Consequences
- No ownership rule changes. Drain and move reuse release-then-acquire. The risk is in routing and admission, so most of the tests below are deterministic server tests with injected faults.
- New surface:
- a marker header on signed node calls (
x-wasmkeep-alarm-handoff), covered by the signature, with the tenant and due time in the signed body. It is stripped from client requests and guests’ calls; - an
ensure_owneroutcome, handled by its callers; - a hand-over job run in the actor’s mailbox, and
Mailboxes::submit_if_resident, which never creates a mailbox; - a release that can be awaited.
- a marker header on signed node calls (
- Each node lists
control/keys every 3 s: one more LIST per node per period, with no object reads. - Unavailability during a hand-over:
- requests queued behind the hand-over job wait for it (the release, milliseconds), then go to the peer;
- the drain waits 30 s for the events queued before the job, then moves on and tries again on a later pass;
- an actor with a socket that will not close stays until it does: a socket’s close event must run with the lease;
- a forward that times out still returns 504, as today.
- Alarm timing:
- a peer that sees a draining node as an actor’s owner looks again after 1 s rather than dropping the alarm, since the owner may be handing it over;
- so does a peer that accepted the actor’s alarm from a hand-off in the last 30 s (or twice ttl plus grace if longer), whoever the record names (amended 2026-11-07): only a draining node hands one off, and its record names it until its release lands;
- a hand-off whose release failed keeps the alarm on the draining node until the release, retried in the background, lands (ADR 0020; before it, until the lease expired);
- a node is not
drainedwhile an alarm it failed to hand off is still with it, nor while a release is pending (ADR 0020).
- Mixed versions:
- older nodes are never eligible;
- an older node’s stale hint costs a 503 for at most 1 s;
- a peer that started draining after this node’s last
control/listing answers one 503, then becomes suspect; - a cluster made only of older peers drains as
stuck.
Found while reviewing, left as is:
- A client can send
x-wasmkeep-forwardeditself on the public port. Only that client’s own request is affected (it can get a 503).
Tests
- Server tests (
crates/wasmkeep-server/tests/cluster/, with fault points):- a drain empties a node; no committed write is lost; requests see only
503 with
Retry-Afteror success; - an alarm due inside a hand-over that is slowed by a fault point fires on another node within 1 s of its time;
- the peer is down during the hand-off: the alarm fires from D’s timer retry, not the sweep;
- a cluster without a cluster port, and one in phase A, both drain;
- a node of another tenant on the prefix is never chosen, and refuses a hand-off;
actors.calland an upgrade on a draining node go to the peer;- an event racing the state’s drop never takes the lease back;
- an older peer (no line 9) is never chosen;
- a failed
control/listing keeps a node draining; - sockets on a moved actor close with 1012, and their close events run;
actor movemoves a resident actor to the named node;- a node alone in its cluster, drained, reports
stuckand still serves its actors.
- a drain empties a node; no committed write is lost; requests see only
503 with
- Unit tests:
- heartbeat lines 7 to 9 read by old and new parsers;
- rendezvous choice and eligibility;
- move keys expiring by last-modified time;
releaseoutcomes.
sim/case (the project rule: changes to leases and failover come with a simulation case). The lease and replication simulations already cover release then acquire by any node, so a hand-over adds nothing there. The new case goes inmadsim_alarm_sweep.rs, with a timer model if it lacks one:- a draining owner hands its alarms off to a peer that may be down or refusing;
- the invariant: every committed due alarm is delivered within the hand-off bound when an eligible peer is up, and within the sweep’s bound otherwise;
- variants that must be caught: completing a refused alarm instead of retrying it, and a draining node that keeps its sweep shard while not delivering it.
Plan
| Slice | Size | State |
|---|---|---|
Heartbeat line 9; cluster status shows it | S | done |
control/ key listing; cluster drain/undrain, actor move CLI | S | done |
| Eligibility and rendezvous choice | S | done |
ensure_owner returns Elsewhere(peer); the five callers handle it | M | done |
| Awaited release with outcomes; hand-over job in the mailbox | M | done |
| Alarm hand-off on a signed call; paced hand-over; sockets first | M | done |
| Sim case, adversarial review of the code | M | done |
Docs: docs/cluster.md, docs/deploy/three-nodes.md | S | done |
| Sweep shards skip draining nodes and other apps | S | done |
actor move (needs the target to acquire first) | M | done in ADR 0017 |
0015. Key-value and queues as SDK patterns built on actors
- Status: accepted and implemented in the Rust SDK (2026-11-05;
crates/wasmkeep-sdk/src/kv.rs,queue.rs,examples/jobs, tests incrates/wasmkeep-server/tests/jobs.rs). The client side in every other SDK (TypeScript, Python, Go, C, Zig) on 2026-11-06, tested against the Rust classes bycrates/wasmkeep-server/tests/jobs_langs.rs. - Date: 2026-11-05
- Milestone: M4
Context
Applications need data shared between actors (settings, a registry, a
cache) and work that must happen later and reliably (webhooks, fan-out).
An earlier plan designed both as platform primitives: a kv interface
over bucket objects with a per-node cache, and a native queue class with a
typed batch export. This decision (“SDK first, hybrid later”) replaces
that for now: a stable API in the SDKs, backed by ordinary actors, so a primitive can
replace the backend later without changing application code. No new host
function, no WIT change.
Everything needed exists for one actor: its SQLite database
(exec-durable for acknowledged writes, ADR 0007), one alarm that survives
failover (ADR 0009), calls to other actors with a caller header the host
vouches for, and one event at a time per actor.
Decision
Key-value: sharded actors
Keys are spread over a fixed number of shard actors of one class,
<class>/shard-0 .. shard-<n-1>, chosen by FNV-1a (64 bits) of the key
modulo n. Not one actor per key: shards hold many keys, so a listing
is n calls rather than one per key, and a million small keys are a few databases rather than a million.
(n equal to the number of keys would be one actor per key; nothing
stops it, nothing recommends it.)
Each shard has one table:
CREATE TABLE wasmkeep_kv (key BLOB PRIMARY KEY, value BLOB NOT NULL,
version INTEGER NOT NULL, expires_at INTEGER) WITHOUT ROWID;
- get reads the row; a row past
expires_atreads as absent. - put writes it with a new version, taken from a counter per shard
(
wasmkeep_kv_meta.seq), so versions are never reused, not even after a delete: a compare-and-set holding an old version always loses (no ABA). - compare-and-set is
putwith an expectation: any, absent, or versionv. The shard reads the current version and writes only if it matches, otherwise409(kv::Error::Conflict). NoUPDATE ... WHERE version = ?race is possible: the shard runs one event at a time. - delete takes the same expectations.
- list(prefix, after, limit) asks every shard and merges in byte order.
- Durability: writes return once durable (
exec-durable) unless the caller asks for a local write (PutOptions::local()). - Expiry: lazy on read, and the shard’s alarm deletes expired rows in batches of 500, then sets itself for the next expiry.
- Read cache for callers: optional, in the caller’s instance
(
Client::with_read_cache(max_age_ms)): reads of other actors’ writes are stale up tomax_age_ms; the caller’s own writes through the client update it. It approximates the planned per-node cache, per actor instead of per node, and is lost with the instance (hibernation, a failed event, a deploy).
Queues: one actor per queue
A queue is an actor with a table of messages:
CREATE TABLE wasmkeep_queue (id INTEGER PRIMARY KEY AUTOINCREMENT, body BLOB NOT NULL,
enqueued_at INTEGER NOT NULL, visible_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT);
CREATE TABLE wasmkeep_queue_dead (id INTEGER PRIMARY KEY, body BLOB NOT NULL, enqueued_at INTEGER NOT NULL,
attempts INTEGER NOT NULL, failed_at INTEGER NOT NULL, reason TEXT NOT NULL);
- enqueue (
send,send_batchup to 1,000) is one multi-rowINSERTrun withexec-durable: an acknowledged send is never lost (ADR 0007), and a batch is all or none.AUTOINCREMENT, so an id is never reused and a late ack cannot settle a newer message. - lease(max, visibility) takes visible messages (
visible_at <= now, oldest first), adds one toattemptsand hides them untilnow + visibility. The pair(id, attempt)names the lease. - ack deletes the row if
(id, attempt)is still the current lease; retry makes it visible again after a delay (default: backoff from 1 s doubling to 5 min). A stale lease (it ran out and the message was leased again) is ignored and reported as such. - Redelivery: a message neither acked nor retried becomes visible when
its lease runs out. A visible message that had
max_attemptsleases (default 5) moves to the dead-letter table, with the last error. - Push delivery, alarm-driven: a queue configured with a consumer
(
Config::consumer("class/name")) sets its alarm to the earliestvisible_at. The alarm leases one batch (default 10), sends it in the body of oneactors.callto the consumer’swasmkeep-queue/v1/deliverpath, and applies the outcomes in the reply (ack, or retry with a delay). A failed or refused call retries the whole batch with backoff; a message the reply does not mention waits out its visibility timeout. Then it sets the alarm again: at once if messages are ready, so batches follow one another, each its own event, and sends get their turn between them. A quiet queue has no alarm and costs nothing. The consumer never calls the queue back during a delivery: that call would re-enter the queue, which is waiting in the chain, and be refused.
Lease and settle writes use plain exec: losing one in a crash means a
message is delivered again, which at-least-once allows.
Who may call
Shards and queues answer only requests carrying x-wasmkeep-caller (set by
the host for actors.call, stripped from outside requests): an outside
client gets 403. handle_allowing narrows it to chosen callers. Serving
the classes with --access CLASS=internal (ADR 0010) refuses outsiders
before an actor is created; the docs recommend both.
The stable part
The application-facing API is kv::Client (get, get_fresh, put,
put_with, compare_and_set, delete, delete_if, list) and
queue::Client (send, send_delayed, send_batch, lease, ack,
retry, settle, stats, dead_letters) plus queue::handle_delivery
on the consumer side. Nothing in those signatures names an actor’s
protocol: a client is built from a class name (KV) or an address (queue).
The protocol between client and actor (paths under wasmkeep-kv/v1 and
wasmkeep-queue/v1, a length-prefixed binary framing) is internal to the
SDK and versioned in its path. If a primitive is added later (a native kv
interface, native queues), the clients switch to it behind the same calls;
a class name or address keeps naming the data, and a migration copies the
shards’ or queues’ tables once.
Limits, stated plainly
- Throughput per shard and per queue: one event at a time, so about one durable write per bucket round trip (the design targets: under 100 ms on local MinIO, under 250 ms on a remote bucket, so roughly 4 to 10 durable writes per second; much more on one node without a bucket). Local KV writes, batched sends and more shards or queues multiply it. Not measured yet; no benchmark is claimed.
- One hop per operation: every operation is an
actors.call, which holds the caller’s event while it runs (and is forwarded when the shard lives on another node). - No transactions across keys or shards, and none between a KV write or a send and the caller’s own database: the caller’s event may write its own tables, call a shard, and trap after; the shard’s write stays. Treat them as side effects, like outbound HTTP.
- A shard or queue cannot use KV or queues of a class already in its call chain (re-entry is refused).
- The shard count is part of the data layout: changing it moves keys, so it needs a migration (read every shard, write through the new count).
- Failover: a shard or queue is one actor, so it is unavailable for a failover window when its node dies: 15 to about 21 s after the node’s last write with the default timings (ADR 0020), then the restore. Pending alarms (push deliveries, expiry) fire a lease period plus grace after they were due, as for any actor.
- Ordering: by visibility time, then id. FIFO only with one consumer, batches of one and no retries.
- Push holds the queue: while the consumer runs a batch, sends to that queue wait (the queue’s event is in progress). Keep consumers short, or use pull.
- Lost replies are ambiguous: a
Callerror on a write or a send means it may have applied. Re-read before retrying a compare-and-set; a send retried after a lost reply may queue the message twice.
Not done here
- Shard and queue classes in languages other than Rust. Every SDK has the
client side (
sdks/typescript,sdks/python,sdks/go,sdks/c,sdks/zig, with anexamples/<lang>-jobsapp each, 2026-11-06); the shard and queue classes stay the Rust components, which any caller deploys as they are. - A dedupe window on send, and a helper for consumers to record handled ids. Applications do it in their own table today.
- No
sim/case: nothing in the host, leases, replication or deploys changes. The behaviour rests on guarantees the simulations already cover (durable writes, single ownership, alarms after failover).
Consequences
- KV and queues ship now, with no WIT, host or wire change between nodes, and inherit replication, failover and the access rules from actors.
- Their limits are an actor’s limits, and the docs say so. Native key-value and queue interfaces stay on the roadmap as the designs for primitives, to start if users hit those limits.
exec-durablecannot end an explicit transaction on a single node (it changesPRAGMA synchronous, which SQLite refuses inside a transaction). The helpers make each durable write one statement instead. (Since lifted:exec-durable("COMMIT")now ends a transaction durably on both a single node and a cluster. The helpers keep their single statements.)
0016. Secrets in the bucket
- Status: accepted and implemented (2026-11-05; all five slices). Revised the same day after an
adversarial review. Changes:
- a crash between create and swap no longer deadlocks the CLI;
- nodes that cannot open the secrets fail closed;
- a plaintext format for any value;
- associated data bound to the bucket prefix;
- a narrower threat model;
- no node-local floor;
- three value layers, and a follower of its own;
- recovery from a lost key.
- Amended by ADR 0022 (planned, 0.4.0): an app’s actor state is a second prefix,
state/<tenant>/<app>/; secrets do not move. - Date: 2026-11-05
- Milestone: M4
Context
Secrets live in files on each node (wasmkeep serve --env CLASS=FILE). In
a cluster the operator copies the same file to every node and restarts
them to change a key. A deploy cannot bring the secrets a new version
needs: wasmkeep deploy --config carries plain configuration, stored in
the clear and versioned with the code (ADR 0013).
What the code does today, and what this ADR builds on:
- Values.
class_envmerges a version’s configuration with the node’s--envvalues over it, by name, when a version is applied or the service starts (actor.rs). The service keeps no separate layer per source. An event takes its class once, at its start. An instance reads its values when it is created, so a change of values drops resident instances at their next event (refresh_class); the database is kept andon-initdoes not rerun (ADR 0013). - Following.
serve --appreads the deployed version before the service starts, then a follower pollsapps/<tenant>/<app>/currentevery 3 s and backs off up to 300 s on errors. A version the node cannot apply makes it report not ready (one flag,deploy_behind), but readiness only feeds/_wasmkeep/readyand metrics: a node that is not ready still holds leases, runs calls and alarms, and takes over actors. env.get(crates/wasmkeep-wit) andenv.rsdocument that secrets come only from the node’s files, and that values change only between events.env::parsereads single-lineNAME=valuewith no escaping.- Cryptography in the tree.
ring0.17 (through rustls and rcgen, ADR 0011) provides ChaCha20-Poly1305, HKDF and a system RNG. - Trust. Whoever can write the bucket can do everything
(security), including deploying code
that reads
env.getand sends the values out. A bucket reader, or a backup of the bucket, sees actor data and configuration. It also seescluster/secretin the clear, which lets it sign node calls in ADR 0011’s default mode; this ADR does not change that, see Consequences. - Names: tenants and classes are
[a-z][a-z0-9-]*; app names are checked only for/. One app per bucket prefix, one tenant per process (several tenants per process is planned).
Decision
Secrets have their own lifecycle
Secrets are not part of a deploy version. Rolling code back must never bring back a revoked key, and changing a key must not need a deploy. An app’s secrets are one sealed object per change, with their own sequence number:
apps/<tenant>/<app>/secrets/<seq:016x> # sealed, immutable, conditional create
apps/<tenant>/<app>/secrets/current # "<seq:016x>\n"
Object names are zero-padded hex so that a listing is in seq order;
current holds the same string.
Writing a change (the CLI):
- List
secrets/and readcurrent. The nextseqis one more than the highest of the listed objects andcurrent. An object left by a command that died before movingcurrentis skipped over, never reused. - Open the set
currentnames (unlessreset, below), apply the change, seal it as the nextseq, and create the object with a conditional create. - Move
currentwith If-Match on the version read in step 1, or create it with If-None-Match the first time. - If step 2 or 3 loses a race, start again at step 1. The change is applied again to the newer set, so nothing another operator wrote is lost.
Sealing
Each object is sealed with ChaCha20-Poly1305 (ring::aead) under a key
derived for this one app: HKDF-SHA256 (ring::hkdf) from the secrets
key, with the salt "wasmkeep-secrets" and, as info, "wasmkeep-secrets 1", the bucket prefix (without slashes around it), tenant and app, each
prefixed by its length as 4 big-endian bytes. A known object in
crates/wasmkeep-server/src/secrets_known_object.txt pins the format; an
independent implementation in Python’s cryptography, written from this
section, produces the same bytes. One secrets key can serve several
apps and prefixes without sharing a nonce budget, and encrypting actor
state later can derive its own keys from it.
wasmkeep-secrets 1\n
<key id>\n
<nonce, 24 hex>\n
<ciphertext and tag, hex>\n
- Key id: the first 8 bytes of SHA-256(
"wasmkeep-secrets-key-id"|| secrets key), in hex. A node with the wrong key, or none, says so by id, never by value. - Nonce: 96 random bits per object, from
ring::rand::SystemRandom. Per derived key that is a handful of objects, far below the 2^32 bound for random nonces. - Associated data: each field length-prefixed:
wasmkeep-secrets 1, the bucket prefix, tenant, app,seq, key id. An object opens only at its own place: copying one from another app, another prefix (staging to production with the same key) or anotherseqfails. - Size: the plaintext is at most
env::MAX_FILE; a node reads at most twice that plus the header (the ciphertext is hex) and refuses a larger object unread. - Plaintext: entries of
<class> <name> <length>\n<value bytes>\n. A value is any UTF-8 (env.getreturns a string), newlines included, so a PEM key fits. Names followenv.rs’s rules (MAX_NAME,[A-Za-z_][A-Za-z0-9_]*), classes follow class names.
A bucket reader without the key sees an app’s secrets’ size and when they changed, not their names or values.
The secrets key
-
Generating one.
wasmkeep secrets new-key --out FILEwrites 64 hex characters from the system RNG to a new file. It is created exclusively, with mode 0600, and refuses an existing file. Redirecting a command’s output would create the file under the shell’s umask, so the command does not print the key. -
Using it.
wasmkeep serve --secrets-key FILE: 64 hex characters and an optional trailing newline, nothing else, in a regular file. A file readable by its group or others (mode & 0o077) is refused, not warned about, and its content is never logged. Symlinks are followed and the target’s mode counts: a Kubernetes secret must be mounted withdefaultMode: 0400(its default, 0644, is refused). On systems without Unix modes, nothing is checked. -
Two keys during a change.
--secrets-keymay be given twice, and nodes open an object sealed under either key. To change the key:- give every node the new key as well, restarting them one at a time;
- run
wasmkeep secrets rotate-keyfor every app sealed under the old key (--alldoes every app under the prefix), with--nodes-have-new-keyto say step 1 is done: no heartbeat says which keys a node holds, and a node without the new key answers 503 for the app; - remove the old key from every node.
Rotating the key does not protect values the old key could open: after a key compromise, change the values too.
-
Losing the key.
wasmkeep secrets reset --new-key FILE --from SECRETS_FILEwrites a whole set, from a file in the plaintext format above, as the nextseq, without opening the current one. Nodes need the new key to open it, so it takes--nodes-have-new-keytoo. -
One key per process. With several tenants per process (planned), one key per tenant, so a team holding one key cannot read another’s secrets.
The CLI
The CLI holds the key, because it seals: the machine running it is as
sensitive as a node. Values come from stdin or a file, never from the
command line, where they would reach shell history and ps.
wasmkeep secrets set --bucket s3://b/prod --app acme/shop --key k --class api STRIPE_KEY < value
wasmkeep secrets set --bucket s3://b/prod --app acme/shop --key k --file changes # several at once
wasmkeep secrets unset --bucket s3://b/prod --app acme/shop --key k --class api STRIPE_KEY OLD_KEY
wasmkeep secrets list --bucket s3://b/prod --app acme/shop --key k # names, classes, seq; no values
- Several changes in one
seq.set --file(the plaintext format) sets several names, andunsettakes several names, each as oneseq, so nodes never serve half of a related change. - Trailing newline. A value from stdin loses exactly one trailing
\nor\r\n; one from--fileis taken as written. - Warnings.
set --classwarns for a class the current version does not have, as--envfiles do. - Older nodes.
setwill read the heartbeats and, while a live node does not read secrets from the bucket, refuse without--force: that node would serveenv.getas none. Version strings cannot tell (every development build says0.0.0), so nodes say it on heartbeat line 10 (secrets), except one run with--no-secrets.resetchecks too. - Deleting old objects. After moving
current, the CLI deletes sealed objects that stopped being current more than an hour ago, timed by their successor’s last-modified time (as ADR 0012 times deploy versions), always keeping the one beforecurrent. No copy of a removed secret stays in the bucket, sealed or not. - ADR 0012’s deploy collection must not reach
secrets/. Itsis_version_idaccepts any lowercase id, so a manifest namingsecretsas its previous version could send it there.secretsandcurrentare refused as version ids (first slice).
Nodes
-
Three layers. The service keeps each class’s deployed configuration, the app’s secrets and the node’s
--envvalues apart, and builds a class’s values from all three, by name, last wins: configuration, then secrets, then--env. A deploy and a secrets change each rebuild from all three, so neither drops the other’s values. A secrets change reuses each class’s compiled component; it only swaps values. A class whose values changed drops its resident instances at their next event (ADR 0013). -
At start.
serve --appopens the current secrets before the service starts, as it reads the version, so no event runs without them. -
A follower of its own. It reads
secrets/currentevery 3 s, with its own backoff and its own flag. A version this node cannot build does not delay a secrets change, and a bad secrets object does not delay a deploy. -
A named object that is missing (deleted after a newer
currentwas written), or unreadable whilecurrentanswers, means readingcurrentagain on the next poll; if it lasts 30 s the node fails closed (below). A corruptcurrentcounts the same. -
Never backwards, in memory. A node keeps the highest
seqit opened since it started and refuses a lower one, or an object whose authenticatedseqis not the onecurrentnames. A restarted node accepts whatcurrentnames, so a bucket restored from a backup works once the nodes restart (running ones refuse the lowerseq, then fail closed). A bucket writer, who can read the secrets anyway (below), gains nothing from rolling a node back. -
Failing closed. A node that knows a set it cannot open (no key, a key id it does not hold, a tampered or oversized object), or that has not opened the set
currentnames for 30 s:- refuses events for the app’s classes with 503 and
Retry-After, logging why; - stops claiming actors, as a draining node does (ADR 0014), so other nodes take them;
- reports not ready.
It never runs an actor with an older or empty set. During a bucket outage every node keeps the set it has and stays as it is: the outage already stops leases.
- refuses events for the app’s classes with 503 and
-
No key, by choice.
serve --appwith no--secrets-keyrefuses to start oncesecrets/currentexists, unless--no-secretssays this node runs without them, with--envonly. A node started before any secrets existed and keyless fails closed when they appear. -
Required values:
wasmkeep deploy --requires CLASS=NAME,NAME...writesrequires <class> <NAME>lines in the manifest (part of the version; kept by later deploys;CLASS=removes them). A class missing a required name refuses its events: a request or call gets 503, an alarm waits (tried again at the longest backoff), a socket closes with 1011. The node logs the class and the missing names once (again after a secrets change), never a value; an empty value counts as set. Required names make the manifest format 2 but do not mark the app (ADR 0013): only rules or configuration do. Older nodes refuse a manifest with a line they do not know (Manifest::parseis strict), so such a version is refused, not misread. A rollback to a version without the lines drops the requirement with them. -
wasmkeep devandservewithout--appuse--envonly, as today.
env.get
The interface does not change. Its doc comment, and env.rs‘s module
doc, do: values also come from the app’s secrets in the bucket, and
change between events when a deploy or a secrets change lands, never
during one. The SDKs’ docs say the same.
Consistency
Eventually consistent across nodes: for up to one poll period, two nodes serve different secret sets. An event sees one set throughout. A change that must be atomic across the cluster (revoke an old key while a new one starts working) is the application’s: accept both for a while.
Moving secrets from --env to the bucket
- Upgrade every node.
- Give every node the key (
--secrets-key), restarting them one at a time. wasmkeep secrets seteach name.- Remove the names from the
--envfiles and restart the nodes one at a time. Until then--envwins by name. The override is logged at warn level, naming the class and variable, so a stale value left in a file is visible.
What sealing protects, and what it does not
- Protects against bucket readers and backups. They cannot read a
secret’s name or value, forge one, or move one between apps, prefixes
or
seqs. - Does not protect against bucket writers. A writer can deploy code
that calls
env.getand returns or sends the values (deploys are not signed yet). A writer can also delete the secrets, making nodes fail closed. Signed deploys, a planned change, are what would narrow this.
Alternatives
- Secrets as part of the deploy version (like ADR 0013’s config). Rejected: a rollback would bring back revoked keys, and a key change would need a deploy.
- Sealing to a public key (X25519 agreement, with nodes holding the private key). It lets CI write secrets without being able to read them, but every change would replace the whole set, or the format would need per-entry sealing and merge rules. Possible later, beside this.
- A KMS (AWS KMS, Vault) from the start. Later, as another source of the secrets key. Not in the first cut: it brings a cloud dependency and IAM to self-hosted clusters that may have neither.
- An envelope per object (a random data key wrapped by the key). It pays off for large data or frequent re-wrapping; an app’s secrets are small, and a key change re-seals them anyway.
- A new crypto crate (
chacha20poly1305,age).ringis already in the tree, widely used and maintained; a direct dependency on it adds no crate. - Pushing secrets to nodes over the cluster port. Rejected like ADR 0008’s push to nodes: the bucket reaches every node, a push reaches the ones up at the time.
- A floor persisted on each node (this ADR’s first draft). Rejected: it breaks bucket restores and wiped data directories, and protects against a party who can read the secrets anyway.
Consequences
wasmkeep-servertakes a direct dependency onring(already in the tree through rustls and rcgen; no new crate), for the AEAD, HKDF and RNG that sealing secrets needs.- One more object read per node per poll (
secrets/current), and a list and a few objects per change. - A node that cannot open the secrets stops taking actors: the cluster needs a node with the key to serve the app.
cluster/secretstays in the bucket in the clear. Sealing it under the same key would make a bucket reader unable to sign node calls; that is a separate change, left for signed deploys.
Tests
- Unit:
- seal and open; the wrong key;
- each associated-data field changed (prefix, tenant, app,
seq, key id), and a tenant/app split moved (ab/cagainsta/bc); - a flipped byte; a truncated object; an oversized one;
- two keys during a change;
- the plaintext format with newlines and every allowed byte;
- key files: 64 hex characters, junk refused, group- or world-readable
refused,
new-key --outcreating 0600 and refusing an existing file; is_version_idrefusingsecretsandcurrent.
- Cluster (
crates/wasmkeep-server/tests/):secrets setreaches every node’senv.getwithin one poll, without a restart or a deploy;- a resident actor reads the new value at its next event, and the same value throughout an event;
- a deploy after
secrets setkeeps the secrets; - the first event after a start sees them;
- rolling code back leaves the secrets as they are;
- a node with another key, or a keyless node once secrets appear, refuses the app’s events with 503, takes no actor over (another node does), and reports not ready;
serve --appwith no key refuses to start once secrets exist, unless--no-secrets;- secrets change while a version the node cannot build is current;
currentnaming a deleted object, and a lowerseqafter a newer one;--envwins by name, logged at warn without the value;- no object in the bucket holds a value or a name in the clear: the test lists every object and searches each.
- CLI:
- values from stdin (one trailing newline stripped) and
--file, never argv; - a command that dies after the create and before moving
current, then anothersetthat succeeds; - two concurrent
sets both land; rotate-key,reset;- old objects deleted by their successor’s time, never the one before
current; setrefused while an older node is live.
- values from stdin (one trailing newline stripped) and
- No
sim/case: no lease, failover or ordering is involved beyond a monotonic counter in memory, which the cluster tests cover (atests/cluster/case is enough for that).
Plan
| Slice | Size |
|---|---|
Sealed-object format, HKDF, key file and key id; is_version_id tightened; unit tests | S |
wasmkeep secrets CLI (new-key, set, unset, list, rotate-key, reset, deleting old objects, older-node check) | M |
Nodes: three layers, open at start, own follower, fail closed and stop claiming, --no-secrets | M |
env.get and env.rs docs, SDK docs, docs/concepts.md, docs/security.md, docs/cluster.md | S |
requires in the manifest, fail closed per name | S |
The feature is complete when all five are in, requires included.
Open questions
- Reloading the key without a restart (on SIGHUP). It would save the two rolling restarts of a key change. Proposed: later, if key changes turn out to be frequent.
0017. Moving one actor to a named node
- Status: accepted and implemented (2026-11-05). Revised the same day after a review of the first draft. That draft let the owner claim the actor back during the move, and sent a take call to nodes that would have run it as a guest request. A move is now a drain of one actor, plus a take call sent only to nodes that advertise it.
- Amended by ADR 0020 (2026-11-07): ownership is a node lease plus an
ownership record per actor; where this record says lease, read the
actor’s hold (
LeaseView: the hold exists and the node lease is valid).sim/tests/madsim_drain.rsmodels the node leases of ADR 0020. - Amended by ADR 0022 (planned, 0.4.0): move keys move under the app’s root, and
actor movegains--app. - Date: 2026-11-05
- Milestone: M4 (the part ADR 0014 left open)
Context
wasmkeep actor move TENANT/CLASS/NAME --to NODE writes
control/move/<tenant>/<class>/<name hex>/<target> (ADR 0014). Every node
lists those keys once per heartbeat period (3 s by default), ignores
those older than 5 minutes (deleting them), and does nothing else with
them. An owner can release an actor, but the next event claims it on
whichever node it lands, often the old owner itself: a node that is not
draining takes back a free lease on its next event for the actor.
What the code offers:
- The drain (ADR 0014). While a node drains,
ensure_ownerreturnsElsewhere { peer }for any actor it does not hold in memory, so requests are forwarded, calls go to the peer, upgrades are relayed, and alarms are handed off (hand_off_alarm). Its hand-over job runs in the actor’s mailbox after the queued events (each already uploaded):- it keeps the lease while a socket is open or closing;
- it reads the committed alarm;
- it closes the instance and database;
- it releases the lease, retrying for up to one lease TTL
(
LeaseReturn::Failedpast that, when the lease expires on its own).
- A forwarded request makes the receiver claim. A node that is not draining acquires a free lease on the first event it gets.
- Signed node calls with a marker header. The alarm hand-off is one: the signature covers the marker. A node from before a marker existed computes another signature, refuses the call as signed, and treats it as an outside request: it runs the guest’s handler.
- Heartbeats. Line 9 is the drain state and line 10 says the node reads secrets (ADR 0016). Readers ignore lines past those they know.
- Hints. A node keeps an actor’s owner for at most 1 s, in the service and in the actor’s own state.
Decision
A move is a drain of one actor
Each node keeps a map of moves under way (actor to target and a deadline
30 s ahead), shared by the service and every actor’s state. While an
actor has an entry, ensure_owner behaves as if the node were draining,
for that actor only, with the move’s target as the peer: it returns
Elsewhere { peer: target } instead of claiming, including after the
lease was released. The entry lives outside the actor’s state on purpose:
the paths that forward an Elsewhere event hibernate the state, and an
evicted or rebuilt state must still forward rather than claim the actor
back. Every path that already handles
Elsewhere then does the right thing with no new code:
- requests queued behind the hand-over, and new ones, are forwarded to the target, which claims on the first one;
actors.callgoes to the target;- upgrades are relayed there;
- an alarm firing during the move is handed off there.
The entry is removed when the move ends: after a successful take (the lease then names the target, so this node forwards to it), or after a failed one (the next event then claims the actor as usual).
The take call
Nothing above makes the target the owner if no event arrives: an idle actor would be claimed again by the next event wherever it lands. So the old owner asks the target to take it:
- Who receives it. Only nodes that advertise it. Heartbeat line 11
holds capability words; a node that implements this ADR writes
take. Older nodes, which would run a take as a guest request (see Context), are never sent one and are not valid move targets. - Shape. A signed node call to the actor’s path, with the marker
header
x-wasmkeep-take, covered by the signature. Its signed body holdswasmkeep-take 1and the tenant. The marker is stripped from client requests and guests’ calls, and honoured only on a verified, single-use call. Time limit: 10 s (a restore can take a few). - At the target, a job in the actor’s mailbox:
- Refuse, without acquiring, while it is draining or cannot open the app’s secrets (503), for another tenant (403), or for a class it does not serve (404).
- Forget any owner it had learned for the actor, in the service and in the actor’s state: a hint left from forwarding to the old owner must not answer for the bucket.
- Check that the actor exists (in the bucket, or locally), the way
existingaccess rules do; if not, 404. A mistyped name never creates an actor. ensure_owner: acquire and restore, no guest code.- Open the restored database (without instantiating the component), read the committed alarm, and arm it unless an earlier time is registered. If it cannot be read, answer 503: the old owner keeps its registration and asks again.
- Answer 200. If the acquire lost, answer 409 with header
x-wasmkeep-holder: <node id>; if the bucket did not answer, 503.
Moving a resident actor (the owner acts)
Once per heartbeat period, a node that is not draining looks at the move keys for actors it holds (resident, with a valid lease). A draining node does not act on move keys: its drain already moves every actor. If several keys name the same actor, the newest (by last-modified time) counts and the others are deleted. For one key, to target T:
- Check T. It must be another node, and an eligible peer (live, same
app,
serving, not asked to drain, not suspect, reachable), and it must advertisetake. Otherwise the key stays and is tried again on later passes until it expires: a target may be starting, or briefly suspect. - Close the actor’s sockets with 1012 and wait (5 s), as a drain does. While a move is pending, upgrades for the actor are relayed to T, so clients that reconnect do not land back here.
- Queue the move job in the actor’s mailbox. It runs after the
queued events:
- it does nothing if this attempt was abandoned meanwhile (each attempt carries a token; a job whose token is no longer current returns at once);
- otherwise it runs the drain’s hand-over steps: keep the lease if a
socket is open or the alarm cannot be read (
Busy), read the alarm, record the move, close, release.
- By outcome:
- Released. Send the take call to T:
- 200, or 409 naming T: the move is done. Forget the alarm here
(only that registration,
clear_if), delete the key, hibernate the stub. - 409 naming another node: another node claimed first, which is correct. Delete the key, log it, hibernate the stub.
- Anything else (refused, 503, no answer): clear
moving_to, keep the key, and try again on a later pass. The actor is free meanwhile; the next event claims it, possibly here.
- 200, or 409 naming T: the move is done. Forget the alarm here
(only that registration,
Busy(a socket), or the queued events not done within 30 s: clearmoving_to, keep the key, try again later.- The release failed (
Failed): the lease expires on its own (ttl + grace). Clearmoving_to, keep the alarm and the key, and try again after that.
- Released. Send the take call to T:
- The deadline. If 30 s pass with
moving_toset and no outcome, it is cleared and the stub hibernated.
At most 16 moves run at a time per node, each in its own task, so a slow move never holds up the drain loop. A key is tried on every pass until it is done, dropped, or expires (5 minutes). A node that does not serve the actor’s class leaves the key to the nodes that do.
Moving an actor nobody holds (the target acts)
A node that lists a move key naming itself, for an actor no node holds (the lease missing, released or expired), runs the take job locally. On 200 (or “already holds it”) it deletes the key. On 404 (no such actor) it logs and deletes the key. Otherwise it keeps the key for a later pass. If another node holds the actor, the target does nothing; that owner acts.
What a move does not promise
- It does not stick. A moved actor that goes idle hibernates on the target after the idle timeout, and its next event claims it wherever that event lands. A move puts an actor somewhere now; it does not pin it (ADR 0014 rejects pinning).
- It can evict. A take on a full target hibernates another idle actor there, as any new actor would.
Alternatives
- The first draft: a service-level routing pin and a take call to any
eligible node. Rejected: events queued in the actor’s mailbox never
consulted the pin, so the owner claimed the actor back. And nodes of
today’s release, which publish
serving, would have run the take as a guest request. - A dedicated
/_wasmkeep/node/takeroute instead of a marker on the actor’s path. Older nodes would answer 404 rather than run a guest. With the capability gate they are never sent a take, so the marker, which reuses the call’s signing and port rule, is kept. - Pinning an actor to a node. Rejected in ADR 0014.
- Relying on the next request (no take). An idle actor would not move.
Consequences
- No lease rule changes. The take is an ordinary conditional acquire, and two nodes racing to claim end with one owner.
- New surface:
moving_toin an actor’s state, honoured byensure_owner;- a second signed marker (
x-wasmkeep-take); - heartbeat line 11 (capabilities,
take); - the take job;
- move keys acted on by owners and targets.
- During a move: the actor’s events at the old owner are forwarded to the target (one hop); those queued behind the hand-over wait for it (milliseconds, or up to the 30 s wait for a busy actor).
Tests
- Cluster tests (
crates/wasmkeep-server/tests/cluster/, plain and TLS):- a resident actor moves under concurrent requests: the target ends up
holding it, with its data, no request fails beyond 503 with
Retry-After, and requests sent to the old owner reach the target; - an alarm due during the move fires on the target on time, and one due after the move fires there once the old owner is stopped (sweep slowed);
- an actor nobody holds is taken by the target; a mistyped name is not created;
- a target without
take(no line 11), draining, of another app, or down: the key is dropped or retried, and the actor stays reachable; - a target with a fresh owner hint for the old owner still takes;
- a socket on the moving actor closes with 1012 and its close event runs first; clients that reconnect reach the target;
- a release that fails keeps the key and the alarm;
- moving to the current owner deletes the key;
- a take marker from a client, or in a guest’s call, is an ordinary request.
- a resident actor moves under concurrent requests: the target ends up
holding it, with its data, no request fails beyond 503 with
- Unit: the take body; the marker covered by the signature; line 11 read by older and newer readers.
sim/case (ordering and leases need one, see CONTRIBUTING.md): extendsim/tests/madsim_drain.rswith a move under concurrent events and an alarm due during it:- invariants: one owner, no alarm lost, the target holding the actor when the move reports done;
- a variant that hibernates the stub right after the release (so the owner claims back) must be caught.
Plan
| Slice | Size |
|---|---|
Heartbeat line 11 (take); moving_to in ensure_owner | S |
| The take call and job (refusals, hints, existence, acquire, alarm) | M |
| Owners act on move keys (check, sockets, move job, outcomes, deadline) | M |
| Targets take actors nobody holds | S |
| Sim case, adversarial review of the code | M |
Docs: docs/cluster.md, ADR 0014’s note | S |
0018. QuickJS, the default TypeScript engine
- Status: accepted and implemented (2026-11-06)
- Date: 2026-11-06
- Milestone: M3 (languages), before the first public release
Context
TypeScript actors embed a JavaScript engine in every component. The
original TypeScript guest strategy (decided 2026-11-03) chose
ComponentizeJS, which embeds StarlingMonkey (SpiderMonkey with Web
APIs). wasmkeep-ts build --engine quickjs added QuickJS-ng through
componentize-qjs, which the pinned jco already ships, with StarlingMonkey
kept as the default “until the numbers decide”.
The numbers are in (the run that decided, with transparent huge pages on:
2026-11-06-dec1a82.csv;
benchmarks shows the current run):
on the counter, QuickJS takes about 3.5× less memory per resident actor
(1.06 MB against 3.7 MB; with transparent huge pages off, which
wasmkeep serve now does by default, 741 KB against 3.4 MB, 2026-11-06-351ea57.csv), less CPU per request (231 µs against 367 µs),
components 8× smaller (1.6 MB against 13.4 MB) and a faster first start
(152 ms against 706 ms). On
plain JavaScript work (js_work: JSON, sorting, strings, a regex, a hash)
it is slower: 6.19 ms against 3.84 ms per request at the median, 1.6× (2026-11-06-dec1a82.csv).
What QuickJS lacks is the Web APIs StarlingMonkey brings: URL,
crypto, fetch, Headers, timers, streams, structuredClone and
console. Neither engine has Intl.
Decision
QuickJS is the default engine; StarlingMonkey stays an option,
wasmkeep-ts build --engine starlingmonkey. The examples’ npm run build
builds on QuickJS and npm run build:starlingmonkey on StarlingMonkey,
and every TypeScript test runs on both.
The SDK closes the gaps that cost nothing:
console: installed on QuickJS. As on StarlingMonkey, it writes to the component’s stdout and stderr, which the node logs.- Clear errors: a
ReferenceErrornaming a global QuickJS lacks gets the engine to use added, from a handler (the 500 body, a hook’s error) and from a build whose module code used it.
The missing globals are not stubbed: libraries test typeof fetch to
pick a fallback, and a stub that throws would break them.
Why
- Memory per actor decides how many actors a node holds, and holding
many is what WasmKeep is for. About 1 MB per resident TypeScript actor
instead of 3.7 MB (741 KB against 3.4 MB with transparent huge pages
off, the default for
wasmkeep serve). - Actor handlers are synchronous. A handler cannot await a
fetchor a timer before it answers, so those matter less in a WasmKeep actor than in a Worker; calls between actors, storage, alarms and sockets go through the SDK on both engines. - The slowdown on JavaScript-heavy code is bounded (1.6×, not the several times that would have kept StarlingMonkey the default), and an actor that needs speed or Web APIs can switch engines with one flag.
- Licensing: QuickJS-ng is MIT and componentize-qjs Apache-2.0; SpiderMonkey’s MPL-2.0 requires making its source available when a component is distributed (legal).
- It was decided before the first public release, so no one depended on the old default.
Consequences
- npm packages that use Web APIs need
--engine starlingmonkey. The error says so. - componentize-qjs is younger (0.5.0) than ComponentizeJS. Both stay tested; StarlingMonkey remains the fallback if QuickJS regresses.
- Benchmark rows: from 2026-11-06,
wasmkeep-tsis QuickJS andwasmkeep-ts-starlingmonkeyis StarlingMonkey. Before,wasmkeep-tswas StarlingMonkey andwasmkeep-ts-quickjsQuickJS. - Guest output (stdout, stderr,
console) reaches the node’s log for every language since 2026-11-06 (monitoring).
0019. Workflows as an SDK pattern built on actors
- Status: accepted (2026-11-06). Built: the Rust runner and client
(
crates/wasmkeep-sdk/src/workflow.rs,examples/workflows,crates/wasmkeep-server/tests/workflows.rs), replay measured, and the TypeScript runner and client withhttp.fetch(sdks/typescript/src/workflow.ts,examples/ts-workflows,crates/wasmkeep-server/tests/workflows_ts.rs), and clients in the Go, Python, C and Zig SDKs (each driving the Rust runner from its jobs example,crates/wasmkeep-server/tests/workflows_langs.rs). Every step listed at the end is done. Revised the same day after an adversarial review of the first draft; the decisions on its open questions are at the end. - Date: 2026-11-06
- Milestone: M4, alongside ADR 0015
Context
Some work spans minutes to days and must survive restarts and failover:
sign a user up, then send a welcome mail, wait three days, send a
follow-up; charge a card, then provision, then notify, retrying each part
on its own; wait for a person’s approval before going on. Cloudflare calls
these Workflows, Temporal and others “durable execution”. Today an
application builds it by hand: a table of states, an alarm, a switch over
the state in on-alarm.
ADR 0015 set the approach for such needs: a stable API in the SDKs, backed by ordinary actors, with no host function and no WIT change, so a platform primitive can replace the backend later. Everything a workflow needs exists for one actor:
- its SQLite database, with
exec-durablefor writes that must survive any node failure (ADR 0007); - one alarm, kept in the actor’s database, that survives traps and failover and is delivered at least once (ADR 0009);
- one event at a time, so no two runs of the same workflow race;
- outbound HTTP and calls to other actors for the work itself;
delete-selfto remove a finished workflow.
The limits that shape the design: an event may use 50 ms of CPU by
default (--cpu-budget-ms) and 30 s of wall-clock time, including the
time it waits on calls; an actor has a single alarm; an actor cannot call
an actor already waiting in its call chain (re-entry is refused); and
handlers are synchronous in every SDK (TypeScript included).
Decision
One actor per workflow instance
A workflow is a class the application serves with the SDK’s runner, for
example signup. Each instance is one actor of that class, named by its
id: signup/user-42. The class uses its actor’s alarm for everything it
schedules, so it does nothing else; the application’s other actors start
and query workflows through a client, as with queues.
Runs happen on the alarm, never in the caller’s call
start and send_event only record (durably) and set the alarm to
now, then answer. The workflow runs in its own on-alarm event:
- the caller’s call is short, so it never holds the caller near its own 30 s;
- the run’s steps are not in the caller’s call chain, so a step may call back the actor that started it (inside the call, that call would be refused as re-entry and burn an attempt);
statusandcancelwait behind at most one run’s event, not behind a caller’s.
Every move forward is durable, and an alarm is always pending
The runner keeps one rule: while an instance is not finished, its alarm
is set, and every state it reports was written with exec-durable.
- Before running anything new in an event, the runner sets the alarm to a watchdog time (now plus the step’s backoff). The alarm lives in the actor’s database (ADR 0009), so a trap, a CPU or wall-clock cut, or a failover leaves it set, and the next attempt comes from it. Without it, a step that trapped in an event nobody retries would leave the instance waiting forever.
start’s input, every recorded step result, a sleep’s wake time, a waited-for event, and every change of status (to waiting, completed, failed or cancelled) areexec-durable. An answer the runner gives (started,waiting until ...) is never undone by a node failing.on-alarmreturns success and keeps its own backoff: the host’s alarm retry is not part of the design.
Steps, replayed
The application writes the workflow as one function, run from the top on every event that moves it forward:
#![allow(unused)]
fn main() {
fn signup(wf: &mut Run, input: &Signup) -> Result<Value, Stop> {
let user = wf.step("create the account", || accounts::create(input))?;
wf.step("send the welcome mail", || mail::welcome(&user))?;
wf.sleep("before the follow-up", Duration::from_secs(3 * 86_400))?;
if wf.wait_for_event("confirmed", Duration::from_secs(7 * 86_400))?.is_none() {
wf.step("send a reminder", || mail::reminder(&user))?;
}
Ok(user.id.into())
}
}
- Positions, not names, identify a step. The runner counts the calls
to
step,sleepandwait_for_eventin a run: thenth call is positionn. A recorded position returns its recorded result; the name given there must equal the recorded one, or the workflow fails with “the code changed the order of its steps” instead of mixing two histories. So a loop may run the same step name many times, each at its own position. step(name, f)at a new position: if a backoff is pending for it (next_attempt_atlater than now), the run stops until then (an event delivered meanwhile does not callfearly). Otherwise the runner counts an attempt and records the error so far (a plain write: a trap insidefthen counts, and only a node failure in between loses the count), runsf, and records the result withexec-durable. A failure (an error returned; a trap or cut, seen as an attempt without a result) setsnext_attempt_atby backoff (1 s doubling to 5 min, 5 attempts by default,StepOptions) and stops. When the attempts run out,stepreturns the last error to the workflow, which may handle it or fail.sleep(name, duration)andsleep_until(name, at): the first time, record the wake time durably (so a lost write cannot restart the sleep from a later “now”) and stop until then.wait_for_event(name, timeout): takes the oldest unconsumed event of that name delivered to the instance and returns its payload, orNoneafter the timeout; until then the run stops. Events are a queue per name, each consumed by one wait, so a loop can wait for “approved” more than once. An event no wait consumes stays until the workflow finishes.- One event runs as far as it can: recorded positions are lookups, loaded in one query; new steps run one after another until the workflow waits, finishes, or the event has used a set share of its wall-clock time, when the alarm (already set to now) continues it in a new event.
The tables:
CREATE TABLE wasmkeep_wf (id INTEGER PRIMARY KEY CHECK (id = 1), status TEXT NOT NULL,
run_id TEXT NOT NULL, input BLOB NOT NULL, output BLOB,
error TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
CREATE TABLE wasmkeep_wf_step (pos INTEGER PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL,
result BLOB, error TEXT, attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER, wake_at INTEGER);
CREATE TABLE wasmkeep_wf_event (seq INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL,
payload BLOB NOT NULL, received_at INTEGER NOT NULL,
consumed_at_pos INTEGER);
What replay asks of the code
Between steps, the workflow function must be deterministic: given the same input and the same recorded results, it must reach the same steps in the same order. Anything that varies (the time, random numbers, a read of this or another actor’s data, an HTTP call) goes inside a step, whose result is then fixed. The position check catches the common breakage (a deploy that reorders or removes steps of running instances). A deploy that changes a step’s result type is not caught by it: decoding the recorded result fails, and the workflow fails with that error. Changes that only add steps after the recorded ones are safe; anything else needs a new class (or a version in its name) for new instances while old ones finish.
A step may run more than once. Its result is recorded after f
returns; if the node dies, the event is cut or the recording fails in
between, the next attempt calls f again. Steps must be idempotent, or
carry an idempotency key to the service they call: Run::step_key() is
the instance’s run_id (random, made at start) and the position, so a
later run under the same id (after retention, below) never reuses a key.
This is the same promise as every durable-execution system that records
results after the call, and the docs say so first.
The client
workflow::Client::new("signup"), every call an actors.call that only
reads or records:
start(id, input): records the input, a newrun_idandrunning, sets the alarm to now. If the instance exists, it changes nothing and returns its status, so a start is idempotent while the instance is kept.status(id):running,waiting(until when, or for which event),completed(with the output),failed(with the error),cancelled, or unknown.send_event(id, name, payload): records the event and sets the alarm to now. To an unknown id: refused (404), not buffered.cancel(id): marks the instancecancelledat once and replaces its alarm with the retention alarm. A step running at that moment is in an event the cancel waits behind, so it finishes first and its result is kept, unused.
Unknown ids leave nothing behind. Any call creates the actor (and its
empty tables, from on-init). An instance with no wasmkeep_wf row answers
404 to status, send_event and cancel, and deletes itself
(delete-self) in the same event.
As with ADR 0015, an instance answers only calls carrying
x-wasmkeep-caller, and the docs recommend serving the class with
--access CLASS=internal. The protocol between client and runner lives
under wasmkeep-workflow/v1 and is internal to the SDK.
Retention
A finished workflow keeps its status and output for a retention period
(7 days by default, Config::retention), then deletes itself from its
alarm. After that the id is unknown, and a start with that id is a new
run: new run_id, its steps run again. An application that must not run
twice keeps the id beyond retention itself, or sets a longer one.
TypeScript needs outbound HTTP first
The TypeScript SDK had no outbound HTTP when this was decided: QuickJS,
the default engine (ADR 0018), has no fetch, and StarlingMonkey’s
fetch is asynchronous, which a synchronous handler cannot await. A TypeScript step could only call
other actors. So the TypeScript runner comes after a synchronous HTTP
helper in its SDK (over wasi:http/outgoing-handler, as the Rust SDK’s
http::fetch), which is useful on its own. That helper is now
http.fetch (sdks/typescript/src/http.ts), and the runner followed it.
Limits, stated plainly
- Each event has the actor’s budgets: 50 ms of CPU and 30 s of wall clock. Replay costs a lookup and a decode per recorded position, in every event: O(n) per event, O(n²) over a workflow’s life. Measured for a Rust guest: about 1.5 µs a recorded step, 7.7 ms for a run over 5,000 (benchmarks); TypeScript is not measured yet, and decoding is about 1.6× slower on QuickJS. A workflow expected to run tens of thousands of steps should end and start a new instance with its state (“continue as new”, left for later).
- One step’s work fits in one event: under 30 s of wall clock, less
what replay took. Longer work is handed to something that reports back
with
send_event. - At-least-once steps, as above.
- No list of instances: an instance is an actor found by its id. A list needs a registry the application keeps (a KV prefix, for example).
- Failover: an instance is unavailable from its node’s last write until 15 to about 21 s later (ADR 0020), then the restore, and its alarm fires up to about 25 s late, as for any actor (concepts).
- Results live in the instance’s database: large outputs belong in the application’s storage, with a reference in the step.
Not done here
- Child workflows, parallel steps, “continue as new”, timers finer than an alarm’s precision. They fit the same tables later.
- A dashboard or a list of instances.
- Runners in Python, Go, C and Zig (see Decisions below). A client in every SDK is cheap: it is calls, like the queue client.
Consequences
- Workflows ship as a library: no WIT, host or wire change, and they inherit replication, failover and access rules from actors.
- The application owns the determinism rule; the runner catches reordered steps and states what it cannot catch.
- A platform primitive later could keep the same client and the same
step,sleepandwait_for_event.
Decisions (2026-11-06)
Decisions on the draft’s open questions:
- Languages: the Rust runner first, then TypeScript once its SDK has synchronous outbound HTTP; clients in every SDK. Runners in the other languages when someone asks.
- Retention: finished workflows are kept 7 days by default
(configurable), then delete themselves; a later
startwith the same id is a new run. - Long steps: a step’s work fits in one event (30 s); longer work is
handed off and reports back with
send_event. Steps spanning events are not planned.
Delivered in this order: the Rust runner and client with the tables above and an example; a replay-cost measurement; TypeScript’s synchronous outbound HTTP; the TypeScript runner; clients in the other SDKs.
0020. One lease per node, not one per actor
- Status: accepted; implemented; measured by the soak after (Verification, item 4). Proposed 2026-11-07. Revised after several rounds of adversarial review, each narrower than the last; the final check of the changed parts, for safety and liveness, found a race between two acquisitions and the resolver (fixed by a lock per actor) and four ways a record could stay held. The simulation tested the protocol mechanically before any code. It lands before the first public release, since changing leases requires stopping every node at once (section 6).
- Amended by ADR 0022 (planned, 0.4.0): ownership records and lease markers move under the app’s root;
nodes/stays shared. - Date: 2026-11-07
- Milestone: M3
- Amends: ADR 0007, 0009, 0012, 0014 and 0017: ownership is a node lease plus an ownership record per actor.
Context
Every actor in memory holds a lease, leases/<tenant>/<class>/<name>, and
renews it with a conditional PUT a third of the ttl (3.3 s) after its
previous renewal returned (renew_loop, lease_hold.rs, before this
record). In the
30-minute soak against R2
(benchmarks),
Cloudflare counted 47,083 PUTs for the bucket
(…-0f2986f-soak-r2-requests.csv),
of which about 23,000 were renewals of 60 busy actors, more than the
21,260 writes those actors made
(…-0f2986f-soak-r2.csv).
The renewal count is derived (PUTs minus writes, snapshots, alarm entries
and heartbeats); a counter added for this record measures it
(Verification, item 1).
On R2 and S3 every PUT is billed, so a cluster’s bill grows with how long actors stay in memory, not with what they do. On any store, 1,000 busy actors make about 300 renewals a second, which a provider may throttle, and each renewal is one more chance of a stalled request on the path that keeps an actor owned.
What the per-actor lease guarantees, and any replacement must keep:
- One owner at a time under clock skew up to
max_skew(wasmkeep_cluster::lease, proven insim/tests/madsim_lease.rs): an owner acts untilsent + ttl - safetyon its clock, another node takes over only aftersent + ttl + graceon its own (grace + safety >= 2 * max_skew), validity comes only from writes the bucket acknowledged, and a delayed write cannot extend anything because every write is conditional. - A generation of its own for every hold: every acquisition, including
a node taking back its own lease, writes generation + 1
(
Leases::acquire,lease/legacy.rs). Segment keys (ADR 0007), alarm-index tags and the owner fence (ADR 0009) and garbage collection’s “a segment this hold wrote” (ADR 0012) all rely on it.
Every node already writes a heartbeat, nodes/<id>, every 3 s, with the
time it was written on its own clock (NodeInfo::written_at_ms) and a
token random per process (instance). Today it is an unconditional
PUT, written after the node lists every heartbeat (heartbeat_loop,
cluster/view.rs, before this record).
Decision
Validity moves from the actor to the node: a node acts as owner only while its node lease is valid, and an actor’s object becomes an ownership record written only when the actor changes hands. Everything else about holding an actor stays as it is today: a hold starts with an acquisition (and a restore), ends with a release (hibernation, a clean stop, a drain or a move, a failed upload), and every hold has a generation of its own. Only renewals disappear.
1. The node lease
The node lease is the heartbeat object nodes/<id>, now written
conditionally, in a new format: header wasmkeep-node 3, with a fixed line
giving its state and the process’s instance. The state is live,
released (a clean stop) or fenced (taken by another node). A
released or fenced object keeps the written_at of the last live
write it replaced, and every reader (membership, alarm shards, routing,
cluster status, the start check) treats it as not live, whatever its
age. Old decoders cannot read the new format, which is intended (section
6).
- One cadence, its own task. The lease write runs on a task of its
own, every
min(heartbeat period, ttl / 3)measured from the previous send, never waiting for the heartbeat listing, the cluster secret orcontrol/(those move to a separate task at the heartbeat period). A configured heartbeat period abovettl / 3is refused. Each attempt has a timeout of a third ofttl - safetyat most (2 s with the defaults) and is retried within the period. - Validity is
written_at + ttl - safetyon the node’s own clock, wherewritten_atis the send time stored in the last write the bucket acknowledged. Readiness follows it: a node is ready only while its node lease is valid (with the existing margin), not while its last heartbeat is younger than three periods. - The first write after the start check is
If-None-Match: *if the check found no object, and otherwiseIf-Matchon the version the check read last. If that write fails, the node goes back to the start check; it does not take a new instance. The start check waits ttl plus grace in every case, including when no object exists (today it returns at once then), so a process whose object was deleted while it still ran is always given time to show itself. A peer’sfencedorreleasedwrite naming the instance the check first read is not a twin: it proves that process lapsed once the object’s keptwritten_at + ttl + gracehas passed on this node’s clock (the same bound as section 2, since a fence may be forced), and the check goes on then, without the rest of its wait. A first write whose reply was lost follows the re-read rule below before going back to the check. - Every later write is
If-Matchon the version of the last acknowledged write. - A failed condition is not proof of a fence. A write whose reply was
lost may have landed, and its retry then fails the condition. On a
conflict the node reads
nodes/<id>once: if the object isliveand names this process’s instance, it is this node’s own write; the node adopts its version (the read’s token: R2’s differs from the write’s) and its storedwritten_at, and goes on. Otherwise (fenced, released, another instance, or absent) the node lease is lost: the node drops every hold at once, without releasing anything, as for a twin, andwasmkeep serveexits at once with a non-zero status: no connection drain, no releases, noreleasedwrite (the fence, or the other instance, already frees the records). A supervisor restarts it (systemdRestart=on-failure, as the deployment guides set); the new process goes through the start check. - A lease that only ran out (the bucket unreachable for longer than
ttl - safety, no conflict seen) is not lost: the node drops every hold locally at once, putting each into the set of pending releases (section 3) with its generation and acquire version, reports not ready, and keeps sending its lease write,If-Matchon the last acknowledged version. Success provesnodes/<id>did not change, so no record naming this instance was freed meanwhile (section 2 frees only on a change of that object): the lease is valid again and the node acquires afresh, at new generations. A conflict then is handled as above. A bucket blip therefore never restarts a cluster. - Twins (two processes with one id) are caught by the condition itself: the second writer’s condition fails and its re-read shows the other instance.
2. Fencing a node and taking its actors
Node B may fence node A only while B’s own node lease is valid, and only
after B has seen A’s object unchanged (the same instance and the same
written_at, which A changes on every write; never by comparing version
tokens) for a full lease period plus one attempt timeout, counted from B’s
first acknowledged lease write after any lapse of its own (so after a
bucket outage every node first renews, and only then may judge the
others). B watches with the heartbeat listing’s bodies, which carry no
version; only the final decision needs one. Then B makes one versioned GET of
nodes/A, and decides on that read alone (never on the listing, the
membership view or an earlier read): if it is live, and its
written_at + ttl + grace has passed on B’s clock, B writes a fence over
it, If-Match on that same GET’s version, state fenced, naming A’s
instance, keeping A’s written_at. If the GET shows a version other than
the instance and written_at B watched, A wrote: B starts watching
again. If A’s next write landed in between, the fence
fails and B reads again (A is alive). If the fence lands, every write A
still has in flight fails its condition, so A’s validity ends at the last
written_at B saw, plus ttl - safety: the same bound as today’s
per-actor takeover. One fence covers every actor of that process. Two
nodes fencing A at once both condition on A’s version; one lands, the
other re-reads a fence and goes on.
A record naming node A and instance i is free only on the evidence
of a read of nodes/A issued after the read of the record (never the
membership view, never an earlier read) that finds a wasmkeep-node 3
object in any state other than live naming i: live naming another
instance (free at once), or fenced or released naming any instance,
free once that object’s kept written_at + ttl + grace has passed on the
reader’s clock. The time bound makes freeing by fence as safe as today’s
per-actor takeover whoever wrote the fence and whenever: the fenced
process’s validity ended at that written_at + ttl - safety on its own
clock. Instances on nodes/A only
change through conditional writes, and a new instance writes only after
its start check waited ttl plus grace, so once i’s object was replaced
i can no longer write. Two cases free nothing, and the record stays held
until A’s object shows one of those states: an absent nodes/A, and an
object in an old format (headers 1 and 2), since an old binary writes its
heartbeat unconditionally and may have overwritten i’s object while i
was still valid; new nodes log an old-format heartbeat as an old version
running (section 6).
nodes/<id> objects are lease state: nothing deletes one while a record
names that id (ADR 0012 never collects them). Revoking a node, which today
means deleting its heartbeat (security), becomes
fencing it: wasmkeep cluster fence <node> writes a fence over the node’s
current object (If-Match on the version it read, keeping its
written_at), without the watch a peer needs, since the time bound above
alone keeps it safe; the node exits at its next write, and its actors are
free ttl plus grace after its last write. When nodes/<id> is absent
(deleted by mistake, the machine gone), the command creates the fence with
If-None-Match: *, its written_at set to the operator’s clock and no
instance: records naming that id are free ttl plus grace later, and a
process still running under it fails its next write and exits (its last
acknowledged write came before the object vanished, so before the fence’s
written_at, within the skew bound). wasmkeep cluster forget <node>
deletes a retired old-format heartbeat once it is stale by the start
guard’s test; no record can name it, since a new node using that id would
have replaced it. After an object was deleted
anyway, a process started with that --node-id frees its actors: it
passes the start check and its new instance frees the old one’s records. Takeover stays lazy, as
today: the next request or alarm for one of the actors acquires it.
3. The ownership record
owners/<tenant>/<class>/<name> (a new prefix, never confused with old
leases/ objects) holds a node id, an instance and a generation, or
names nobody (released), keeping the generation.
- Acquire, only while this node’s lease is valid (its first
livewrite acknowledged or adopted, and not run out), checked right before the record write. Read the record (version r), then, if it names nobody, is free (section 2, withnodes/Aread after r), or names this node’s own current instance (a hold of this process that ended, for example one whose release is still being retried), write it withIf-Matchr at generation + 1, naming this node and instance. With no record, see section 6 for the first generation;If-None-Match: *. No acquisition ever reuses or lowers a generation, so every hold keeps a generation of its own and ADR 0007, 0009 and 0012 hold unchanged. The new owner then restores, as today (it lists after acquiring). - Own. A node owns the actor while it holds the hold it acquired (in
memory) and its node lease is valid.
LeaseView::is_validbecomes “this hold exists and the node lease is valid”; the acknowledgement rule ofReplica::put, alarm delivery, the alarm index rules, garbage collection,exec-durableand the event cut on a lost lease all ask it, as today. - Release. Hibernation, a clean stop, a drain, a move and an abandoned
hold (a failed or interrupted upload) write the record naming nobody,
If-Matchon its version, generation kept. The node stops acting as owner before sending it, whatever the reply. If the release fails or its outcome is unknown, the node keeps it in a set of pending releases, each keyed by the actor, this instance, the hold’s generation g and the version v of the hold’s acquire (holds are not renewed, so that is the record’s version while the hold lasts). Every retry is the same write,If-Matchv, and never adopts a version read later (the re-read-and-adopt rule of section 1 is fornodes/<id>only). A pending release ends when it lands, or when a read shows the record no longer names this instance at generation g (nobody, another owner, a higher generation, or no record at all). A new acquisition of the same actor on this node does not cancel it: if the acquisition lands, its write moved the version past v, so the release can never apply, and the next read shows generation g + 1, which ends it. Retries back off, and pause while the node lease is invalid. - An acquire that did not succeed (no reply, a timeout, a cancelled
write, and a conflict too, since the client retries internally and a
first attempt may have landed) first reads the record back once, still
under the actor’s lock. Naming this instance at the generation tried,
it is this write (only this node writes this instance, one acquisition
of an actor at a time, and the generation is the attempt’s): the
acquisition holds it, at that read’s version. Replaced since the read
the write was conditioned on, by another holder whose node lease does
not free it, the race is lost: that node owns the actor, and the
request goes there. Otherwise (the record unchanged above all: the
write may still be in flight, and land), or when a read fails, it is
kept in the same set as an unresolved
acquire (actor, this instance, the generation tried). The background
task reads the record until it can decide: if it names this instance at
that generation and no hold for the actor exists here, it releases it
(
If-Matchon that read’s version: only this node could have written this instance at that generation); if that release fails or its outcome is unknown, it becomes an ordinary pending release keyed on that read’s version. If the record names anything else, or a hold for it exists here, the entry ends. The drain and move outcomes that today say “the lease expires on its own” (HandOver::Expiring,MoveOut::Expiring, ADR 0014 and 0017) become “the release is pending”: the actor is freed when it lands, or when the node stops or is fenced. - One lock per actor on each node orders acquisitions and the resolution of unresolved acquires: an acquisition holds it from its read until its hold is registered (or its failure recorded), and the resolver holds it from its read until its release’s reply. Without it, a resolver reading a record that a second, in-flight acquisition of the same actor just wrote at the same generation would release that new hold: two owners with no clock skew involved.
- Who is the owner (for routing): one GET of the record, within the
250 ms the routing path allows today (
ADMISSION_WAIT), and the named node’s liveness from the membership view the node already keeps (afencedorreleasedobject, or a node missing from the view, is not live). Routing never readsnodes/<id>per request; only an acquisition reads it fresh (section 2). A request reaching another node is forwarded to the named node, as today with leases; a record naming nobody, or a free one, is acquired where the request lands.
4. A clean stop and a drain
A clean stop, in this order: release every hold as today and wait for
the releases (bounded, as today); then make the node lease locally
invalid on the whole node, so every LeaseView::is_valid says no,
uploads still in flight return Late, and the lost-lease cut ends any
event still running (a straggler past the stop limit is cut, not left
acting); stop the lease task for good, without aborting an attempt in
flight (it finishes or times out, and an acknowledged one gives the
version to condition on; it extends nothing, since the node is already
invalid); and only then write the node lease released, If-Match on
that version, naming its instance, keeping its last written_at. On a
conflict, the section 1 re-read applies once: its own live object, adopt
and retry; a fence, done (it frees the records too). A write that
fails is tried again, for one attempt timeout and a second more, within
the stop’s limit; past it, the node lease stays live:
peers free its actors only by fencing it, and a restart under its id
waits ttl plus grace. That write frees every record whose release had
not landed. A drain releases
each resident actor as today and reports drained only when its set of
pending releases is empty, so no record is left naming a live node that
no longer serves it.
5. Timing, unchanged
ttl 10 s, grace 5 s, safety 2 s, max skew 1 s, a lease write every third of the ttl, and another node may take over 15 s after the last acknowledged write, as today, plus whatever part of a lease period and an attempt timeout the fencer had not yet watched.
The renewal arithmetic, as built: a write goes every
heartbeat period, at most a third of the ttl (3 s by default), from the
previous send; each is bounded by a third of ttl - safety (2.67 s); the
lease is valid ttl - safety (8 s) after the send of the last acknowledged
write. After a write acknowledged at t, the next go at t + 3 and
t + 6: the first has its whole attempt, the second must be answered
within 2 s. So a node has two retries in practice before its lease runs
out; a third would need writes every (ttl - safety) / 3. A node whose lease writes keep failing loses
every actor at once instead of one by one;
wasmkeep_node_lease_failures_total replaced
wasmkeep_lease_renew_failures_total (removed with per-actor leases) as that node
signal, to alert on at its first increment.
6. Upgrading from per-actor leases
Old and new nodes must never run together, and an old binary must never take an actor the new version owns, whether the new version found the actor or created it.
- Start order. The bucket check first; then the start guard and the start check run together, so a new node serves about 15 s after it starts when the old nodes stopped at least 10 s before.
- Start guard. A new node waits (it does not exit) while any heartbeat
in an old format (headers 1 and 2) was written less than its horizon
plus ttl plus grace ago, re-reading every half period and logging how
long is left. A heartbeat whose
written_atis in the future lapses at the latest horizon plus ttl plus grace plus max skew after this node first read that same body (measured on this node’s monotonic clock), with a warning naming the object, so a retired node’s clock cannot hold the cluster forever (heartbeats and per-actor renewals are separate tasks: a stalled listing stops an old node’s heartbeat while its renewals go on). An old heartbeat that keeps being rewritten means an old node is running: the new node exits with why, as the start check does for a twin. An old binary started after the switch cannot read new heartbeats; its own start check then reports its id as taken if a new node uses it, and every actor it reaches carries a marker (next item). After the switch, an old-format heartbeat that is fresh by the same test, or changes between listings, is an old version running: new nodes log it as an error andcluster statusshows it. A stale one is a retired node’s:cluster statusshows it as retired, old format, without an error. Either way it frees no record (section 2). - First acquisition of an actor reads
leases/<tenant>/<class>/<name>. If it is still live by the old rules (expires_at + gracenot passed), whatever node it names, this node’s own id included (an old record has no instance to tell a predecessor from a past hold of this process): the actor is held, refuse as for a held lease today. If it is absent, create awasmkeep-lease 2marker withIf-None-Match: *; if it is present and not live, replace it with the marker,If-Matchon its version. Old code reads the marker as corrupt and never takes it over (LeaseRecord::decodeaccepts onlywasmkeep-lease 1). The marker carries the generation to seed:max(the old generation, the highest generation in the actor's wal/ and snapshot/ keys). Then createowners/at that generation + 1. A crash between the two writes leaves the marker, from which the next acquisition seeds the same way. - The procedure in Upgrading WasmKeep
changes with this version: start the new nodes once
cluster statusshows no live node, and expect them to serve about 15 s after they start. Its “going back is the same procedure” no longer holds after this upgrade: a per-actor-lease binary refuses every marked actor (by design). Reverting needs a tool that rewritesleases/fromowners/, built only if asked.
Cost, after
Per actor, once and for good: its first hold under the new version also
reads leases/, lists its keys to seed the generation and writes the
marker (a GET, a LIST and a PUT), and leases/ markers and owners/
records are two small objects per actor that are never collected (an old
binary must keep being refused; ADR 0012 is amended to say so).
Per node, unchanged in count: a lease write every third of the ttl (the heartbeat, now conditional) and the heartbeat listing, now on its own task. Per hold: a record read and write to acquire, a write to release, and the restore’s listing, as today. Renewals disappear: in the soak, about 23,000 of 47,083 PUTs, so a busy actor costs what it writes and what its holds cost, not how long it stays in memory.
The listing (unchanged by this record, which made it
also what peers fence by): each node lists nodes/ every heartbeat period
(3 s by default) and reads each node’s object, one LIST and then one GET
per node, one after another. A cluster of N nodes makes N x (1 + N)
requests a period: 12 per 3 s at three nodes (about 260,000 GETs and
86,000 LISTs a day, about $0.50 a day at AWS S3 Standard list prices), 420
at twenty (about 12 million GETs and 580,000 LISTs a day, about $8). One
listing’s duration grows with N too (wasmkeep_listing_seconds). A planned improvement reads a body only
when its listed ETag changed.
Bounds added after the first implementation, each to keep one slow bucket from holding everything up:
| Bound | Value | Why |
|---|---|---|
Fences at once (FENCE_CONCURRENCY) | 8 | fences run on a task of their own, beside the listing, so a hung fence never delays the listing and the pins it refreshes |
Pending entries at once (PENDING_CONCURRENCY) | 32 | after a lapse with thousands of holds, a pass one at a time took minutes; a drain and a shutdown wait on it |
| Refusals in a row before a pass stops | 32 | a refusing or throttling bucket gets no burst of thousands of writes beside the node lease’s renewals |
A foreground release (RELEASE_TRIES) | 1 s, then parked | hibernation and hand-overs do not wait on a slow bucket; the background retries keep the full attempt |
A handed alarm looked at again (handed_recheck_ms) | 30 s, or twice ttl plus grace | the sender’s release may be pending; its record frees on the release or a fence |
Consequences
- One node lease covers every actor of a node: losing it (the bucket
unreachable from that node for longer than
ttl - safety) drops them all together. Today the same outage drops them one renewal at a time. - A node restarted under its id takes its predecessor’s actors back as they are next used, at new generations, after its start check.
wasmkeep cluster statusshows each node lease’s state; the metrics gain successful lease writes and fences.- The upgrade is one-way (section 6).
Verification
- Measure first. A counter of successful renewals
(
wasmkeep_lease_renewals_total) and the soak’s request counts, so the before and after are measured, not derived. - Simulation (
sim/): a node-lease model in the style ofmadsim_lease.rs, with lease writes delayed and applied late, lost replies and their retries, fences racing heartbeats, skew at the bound, restarts under the same id and releases with unknown outcomes. The invariant is the one checked today: never two owners acting for one actor, and never one generation for two holds. It must fail against a deliberately broken model (a fence that is not conditional; a wake that reuses a generation) before it passes against the design. tests/cluster/: a fenced node drops its actors; a heartbeat that lands after a fence is refused; a lost reply is adopted, not taken for a fence; a restarted node takes its predecessor’s actors back; a deleted heartbeat object frees nothing; after a bucket outage no node fences another before renewing; a clean stop cuts a straggler before writingreleased; a drain ends only when no release is pending; a fence decided on a stale read is refused by its condition; a pending release never applies to a later hold of the same node; an acquire with an unknown outcome is released if it landed; an old-format heartbeat frees nothing;wasmkeep cluster fencemakes the fenced node exit; a lost acquire followed by a second one landing at the same generation is never released by the resolver while its hold lives (also asim/case); the upgrade seeds generations above every existing segment, marksleases/for found and new actors, and refuses a live old record that names the starting node’s own id.- The soak, before and after (
bench/soak.sh, same bucket and machines): renewals gone from the PUT count, nothing lost or duplicated, failover no slower. - An adversarial review of this record, then of the code.
Recorded runs:
- The renewal counter (
wasmkeep_lease_renewals_total) measured the before: the per-actor soaks in benchmarks (three 30-minute runs on R2 and OVHcloud, and the 12-hour run on three VPS: 5,024,032 writes, none lost). The after is item 4. madsim_node_lease, 200 seeds: 2,776 acquisitions, never two owners, never one generation for two holds; the three broken models caught at seeds 62, 0 and 33. Since then the replication, alarm-index, drain and move simulations drive node leases too (each also run at 2,000 seeds), and all run at 200 seeds in./scripts/check.sh --full. The firstcheck.sh --deeprun (20,000 seeds each):madsim_node_lease317,342 acquisitions with a twin process, every broken model caught; every other simulation passed after one checker fix inmadsim_drain(its deadline ignored a third node’s bucket outage).- These cases run in
tests/cluster/andcluster/tests.rson everycheck.sh --full; the S3-gated suites pass against moto on the node-lease code, and the S3 tests against Cloudflare R2 and OVHcloud Object Storage. AWS S3 has not been run. - The 12-hour soak on the same three VPS, bucket and cadence as the
per-actor run (build
82dab61, the label the result file names carry for the build measured, before the bounded segment uploads; see benchmarks): nothing lost or duplicated over 3,659,687 writes and 35 kills; every alarm fired. Renewals are gone from the request counts (PUTs other than segments 0.80 per node and second, against 3.1 to 7.4). Failover is not quite “no slower”: writable again 18.2 s at the median against 17.0 s, 24.4 s at p99; the worst, 89.6 s, was mostly one slow restore (56 s, 55 reads in turn, none cut), a restore cost both designs share (the per-actor run’s worst was one too), not the lease. Durable writes ran slower during that run; the runs do not tell the bucket’s speed from the design. Alternating 30-minute blocks run the same night (benchmarks) do: node leases made 27 % fewer writes than per-actor leases (the tail: durable p99 about 1.7 times), with actors writable again 1 to 2 s later at the median. A cost of the design in the measured build82dab61, not of the bucket’s speed; where in the durable write it lies is open. - Several adversarial review rounds of this record, then a review of each code change.
Alternatives
- Longer per-actor leases (ttl 60 s): six times fewer renewals, and failovers six times slower. Rejected: failover time is a promise.
- Keeping ownership across hibernation (this record’s first draft): saves a write on each hibernation and wake, but a woken hold then shares a generation with the previous one, which defeats the ADR 0009 owner fence and garbage collection’s rule, and drains, moves and clean stops would also have to hand over actors that are not in memory. Rejected: renewals are the cost that matters.
- One object listing every actor a node holds, renewed as one PUT: one renewal per node, but a large object rewritten every 3 s, and acquisitions on one node contend on it.
- Renewing only actors that ran recently: fewer renewals for idle actors, but busy ones, the expensive case, still renew every 3.3 s.
Implementation
- Heartbeat format 3 (the node lease’s lines, then the heartbeat’s
fields) is read by
wasmkeep_cluster::membership; fences and releases keep those fields. The fence watch isnode_lease::FenceWatch, and the routing read of a recordOwners::other_owner. Ownership records stay innode_lease::Ownersrather than behind theLeasesAPI: the server calls them directly. - The node lease is the heartbeat, written conditionally on a task of
its own every heartbeat period (a period above
ttl / 3is refused), each attempt bounded by(ttl - safety) / 3; a failed condition is re-read once, and a lost lease stops the node as a twin does; readiness follows the lease’s validity; the start check reads with versions and waits ttl plus grace in every case; a clean stop writesreleasedlast. - Fencing. Peers fence stale nodes (
FenceWatchover the listing’s bodies, the fence decided on one fresh read, only while this node’s lease is valid); metrics count lease writes, their failures and fences;cluster statusshows lease states.cluster_tlsdrops a fenced or released node’s pins at the next listing, since it trusts only live nodes; a test says so. - Holds on ownership records (
cluster/lease_hold.rs): a hold is valid while it exists and the node lease is valid with no lapse since it was acquired; a lapse ends every hold at once into the pending releases, a lost lease or a twin drops them; pending releases and unresolved acquires are retried in the background while the lease is valid, under a lock per actor; drains report drained only with nothing pending; a clean stop releases, then makes the lease locally invalid, then writesreleased; routing reads the record and trusts a holder only if the view shows its named process live. The per-actor renewal metrics are gone (wasmkeep_node_lease_failures_totalreplaces their alert). - The upgrade path: the marker (
mark_old_lease: created or replacing an expired old lease, seeding the first record above its old lease’s generation and every generation the actor’s keys carry; a live old lease refuses, whatever node it names), the start guard (run with the start check; a rewritten old-format heartbeat refuses the start; one dated ahead lapses on this node’s count), old versions logged while running and shown bycluster status, and the start check’s shortcut on a fence or release of the process it first read (node leases need no age rule; old formats keep it). Upgrading WasmKeep describes the procedure. - Operator commands:
cluster fence(operator_fence: over the current object, or created when absent; nothing over a fence or an old format) andcluster forget(a stale old-format heartbeat only). ADR 0012 is amended (heartbeats, markers and records are never collected), and the revocation in security is rewritten.
0021. The app model: tenant, app, class, actor, front
- Status: accepted (2026-11-12), partly planned. Built in 0.3.0: the
first
deployof an app refuses a second app of its tenant on a bucket prefix, and new apps’ names follow the class grammar (crates/wasmkeep-server/src/apps.rs,crates/wasmkeep-server/tests/cluster/deploys.rs,crates/wasmkeep-cli/tests/deploy_config.rs). Planned for 0.4.0, each with its own ADR: the front, app-owned actor state (ADR 0022), the project file. Later: several apps on the same nodes. - Date: 2026-11-12
- Release: 0.3.0 and 0.4.0
Context
wasmkeep deploy --app acme/shop ships actor classes, their access rules
and configuration as one version, and serve --app acme/shop follows it
(ADR 0008,
ADR 0013). Secrets are kept
per app (ADR 0016). But the rest of an
app is not one thing yet:
- Actors are keyed by tenant, class and name (
actors/<tenant>/...,owners/<tenant>/...), not by app. Two apps of one tenant on one bucket prefix share every actor whose class name they share. Only a node refused to start, and only while a node of the other app was live;deploychecked nothing. - The stateless component that serves everything outside
/actor/is a file on each node’s command line, outside the deployed version: it is updated by hand on every node, and a rollback does not reach it. - “Tenant” was used for two things: the owner of apps, and a customer of an app.
- The app name had no grammar; the tenant’s came from actor addresses.
Decision
Words
- Tenant: the owner of apps: a team, or a client of the operator.
It groups apps. A customer of your SaaS is not a tenant: it is an actor
name (
account/customer-42), or data inside actors. - App:
tenant/app, for exampleacme/shop. The unit of deploy, version, rollback and secrets, and, from 0.4.0, of actor state. Writtentenant/appwherever a user types it. Staging is another app (acme/shop-staging), with its own state. - Class: a kind of actor in one app: a component, its access rule and
its configuration.
userinacme/shopanduserinacme/adminare unrelated once apps own their state. - Actor: one instance of a class, by name, with its own database.
Inside an app it is addressed
class/name. The address a guest sees staystenant/class/name(planned, 0.4.0: read relative to its own app), so no WIT change and no rebuild. - Front (planned, 0.4.0): the app’s optional stateless
wasi:httpcomponent. It receives every request outside/actor/and/_wasmkeep/, holds no state, calls the app’s actors with the sameactors.callactors use, and ships in the app’s version, so a deploy and a rollback move front and actors together. Routing, sign-in and hostnames are handled there, in the app’s own code.
Tenant and app names of a new app match [a-z][a-z0-9-]{0,63}, the
grammar of classes. An app that already exists keeps its name: the rule
is checked by the first deploy, while the app has no current.
An app owns its actors (planned, 0.4.0)
Two apps never share actors, whatever bucket prefix they use: each app’s actor state is kept apart from other apps’, in the bucket and on disk (the layout is fixed by ADR 0022). Existing data moves there once, by a migration, rather than keeping today’s layout beside the new one for good. The layout and the migration are fixed by ADR 0022.
Until then, one app per tenant per bucket prefix, enforced by
deploy: the first deploy of an app lists apps/<tenant>/ and refuses
when another app there has a current, naming it and saying what to do
(another prefix, or another tenant when another owner runs the app; for
an abandoned app, the keys to delete). A listing that fails refuses too. Two apps of one tenant already
deployed together on one prefix are left as they were, and deploy and
serve --app warn about them by name; a further one is refused. The check adds
no object to the bucket. The node’s start check against a live node of
another app of its tenant stays.
Who may reach a class (planned, 0.4.0)
With a project file, wasmkeep.toml, a class is internal (reachable
from its app’s actors, alarms and front only) unless it says otherwise.
Each class’s rule is written in the version, open included: it is never
derived from whether the app has a front, so adding or removing a front,
or rolling back across one, does not change who reaches an actor. Apps
deployed with flags only keep today’s default, open
(ADR 0010).
Several apps on the same nodes (later)
Until then a node serves one app; a machine can run several nodes. Shared nodes come later, for one owner’s apps and for tenants the operator trusts. Their per-app limits protect against a noisy neighbour; they are not isolation from a hostile one (security).
Consequences
- A first deploy with a 0.3.0 CLI no longer creates a second app of a tenant on a prefix without a word. It does not catch pairs deployed before the check (they keep sharing actors, with a warning), deploys by older CLIs, two first deploys at the same moment, or a new app that finds the actors an abandoned app left.
- A tenant is blocked on a prefix by an app nobody runs any more, until
someone deletes its keys under
apps/<tenant>/<app>/. There is noapp delete; the error and the deploy docs say which keys to delete. The abandoned app’s actors stay under the tenant, and the next app finds those whose class name it shares. - Two concurrent first deploys of two apps of one tenant can both pass the listing. The check prevents mistakes, not races; app-owned state removes the need for it.
- Apps split onto separate prefixes to satisfy the check stay where they are when 0.4.0 lifts it: moving an app to another prefix breaks its sealed secrets (ADR 0016).
- Metric labels,
cluster statusand errors are to use the same words (tenant, app, class, actor) as they gain app-level detail (planned).
Alternatives
- Keep apps on the bare tenant keys for good, and add an app root only for new apps. Two layouts forever: every later feature that touches keys (garbage collection, export, deleting an app) would be written and simulated twice. Rejected for a one-time migration.
- A marker object per tenant that claims the prefix for one app. A new object and format for a rule that 0.4.0 removes. The listing needs neither.
- Derive the access default from the front’s presence. Rejected above: the same manifest line would mean two things.
0022. App-owned state and the one-time migration
- Status: accepted, not yet built (planned for 0.4.0). Built in 0.3.0:
the guard only (below, “Old binaries”): every command that opens a
bucket refuses one that carries the migration’s markers
(
crates/wasmkeep-cli/src/main.rs,MIGRATION_MARKERS). - Date: 2026-11-12
- Release: 0.4.0
- Amends: ADR 0007, 0009, 0012, 0014, 0017 and 0020: actor state is keyed under its app’s root. ADR 0016 only for the root: secrets do not move.
- Follows: ADR 0021 (“An app owns its actors”).
Context
Actor state is keyed by tenant, class and name: actors/<tenant>/...,
owners/<tenant>/..., leases/<tenant>/..., alarms/<tenant>/... and
control/move/<tenant>/.... Two apps of one tenant on one bucket prefix
share every actor whose class name they share. ADR 0021 decided that an
app owns its actors, and that existing data moves once into one layout
rather than keeping two layouts for good. This record fixes that layout
and the migration.
Decision
One layout
Each app’s actor state lives under its own root,
R = <prefix>state/<tenant>/<app>/, beside apps/:
| Family | Before 0.4.0 | From 0.4.0 |
|---|---|---|
| history (ADR 0007) | actors/<t>/<c>/<n>/{wal,snapshot}/... | R actors/<c>/<n>/{wal,snapshot}/... |
| ownership record (ADR 0020) | owners/<t>/<c>/<n> | R owners/<c>/<n> |
| old lease marker, the generation seed | leases/<t>/<c>/<n> | R leases/<c>/<n> |
| alarm index (ADR 0009) | alarms/<t>/<at>/<c>.t/<n>/<gen>-<nonce> | R alarms/<at>/<c>.t/<n>/<gen>-<nonce> |
| moves (ADR 0017) | control/move/<t>/<c>/<n>/<target> | R moves/<c>/<n>/<target> |
| deploys, secrets | apps/<t>/<a>/... | unchanged |
| shared by the prefix | nodes/, control/drain/, cluster/secret, probe/ | unchanged |
- Key tails are byte-identical. Everything after the family keeps today’s encoding, so the migration only changes the front of a key and never decodes a name (the segment encoding is lossy for long names).
- The tenant and app are written raw, as under
apps/; the 0.3.0 name grammar keeps them safe. state/sits besideapps/, not inside it, so the deploy garbage collector, which treats every child ofapps/<t>/<a>/as a version, can never reach it. An app is two prefixes,apps/<t>/<a>/andstate/<t>/<a>/, for deleting, exporting and access policies alike.- On disk the database moves to
<data_dir>/state/<t>/<a>/actors/<c>/<n>.db(each segment encoded), with its sidecars beside it. - The address an actor sees stays
tenant/class/name. It is replicated and guest-visible; the app comes from where the actor lives. - Secrets do not move. Sealing binds the format, prefix, tenant and app (ADR 0016), not the object key.
The implicit app
serve --actor and dev run as the app default/default. Nothing
changes for their users. deploy --app default/default is allowed: it is
how an --actor cluster moves to deploys and keeps its state.
The app between nodes
Every node-to-node call carries the caller’s app in a signed header,
x-wasmkeep-app: tenant/app (ADR 0011’s signed list). A node that serves
another app refuses the call before reading its body: 503,
Retry-After: 1, “not this app’s node”. The tenant checks on drain and
move bodies pass between two apps of one tenant; the header does not.
Heartbeats always name the app, default/default for --actor nodes.
actor move gains --app tenant/app.
The migration: offline, by a command, once
wasmkeep cluster migrate --bucket s3://B/P # dry run, read-only
wasmkeep cluster migrate --bucket s3://B/P --apply [--heir T/A]...
wasmkeep cluster migrate --bucket s3://B/P --rollback # until a 0.4.0 node starts
wasmkeep cluster migrate --bucket s3://B/P --remove-old # once the app answers on 0.4.0
It runs while every node is stopped, which an upgrade across a lease
change already requires (ADR 0020, section 6). The dry run writes
nothing: it prints the plan, the live nodes it sees, object counts and
an estimate of the time. Each class goes to the one app whose manifest
deploys it; a class in no manifest goes to the tenant’s only app (or to
default/default); a class two apps deploy, or a tenant with several
apps, needs --heir, with no default.
--apply, in outline, resumable from any step, with one commit per
tenant:
- Plan and refuse: an unresolved heir, a live node lease (the ADR 0020 rule; migrate fences nothing), an object too large for one server-side copy, a different earlier plan.
- Lock: create
cluster/migrationholding the plan, each app’s currentcurrenttoken and the fullnodes/set. - Copy server-side into R. History objects, which are create-only, are skipped when present; mutable objects (records, markers, alarms) are copied every time.
- Verify: list the source again until it is unchanged, compare mutable objects byte for byte, check that a restore plan on R ends at the same head as on the source, and delete keys in R the source no longer has.
- Barriers: mark every app’s
currentwith a new token,3, that older binaries cannot read, and overwrite each bare ownership record, conditionally on what was copied, with a tombstone naming the new root. A conflict means an old node took the actor: abort. - Final proof: list
nodes/again; any new or changed key means a node ran, and the run aborts with nothing committed. - Commit: create
cluster/migrated/<tenant>. From here, R is the truth for that tenant. Then unlock.
A node acknowledges a write only while its node lease is valid (ADR
0020), and holding one means rewriting nodes/<id>. Refusing live nodes
first and proving after the barriers that none ran is what keeps every
acknowledged write.
--rollback restores the ownership records and the current tokens and
deletes R, and only while nodes/ is unchanged since the commit, that
is, while no node of any version has run. After the first 0.4.0 node
starts, the migration is one-way. The bare keys stay until
--remove-old, which deletes them, keeps the tombstones for good, and
refuses any bare key written after the commit (an old binary ran). It is
meant for the same day.
A 0.4.0 node or CLI checks before any write: it refuses while the lock exists, and refuses a tenant with bare keys and no commit, printing the command to run. A node renames its local data directory at start: aside in cluster mode (the bucket is the truth), into the new layout when the local copy is the only one, leaving a file at the old path so that an old binary fails instead of starting empty.
Old binaries
- 0.3.0 refuses
serve,deployanddevon a bucket that hascluster/migrationor anycluster/migrated/<tenant>. Both names are reserved from 0.3.0. - 0.2
serve --appanddeploycannot read token3, and stop. - 0.2
serve --actorgets the tombstone on an existing actor and answers 503. A brand-new actor it creates under the bare keys is found by--remove-old; running 0.2 on a migrated bucket is unsupported. - Mixed 0.3 and 0.4 nodes are not supported: their calls fail on the signed header.
What this amends
- ADR 0007: history keys move under R; the protocol is unchanged.
- ADR 0009: the alarm index moves under R; each node sweeps its own app’s index only.
- ADR 0012: history collection walks R. Copied objects get a new last-modified time and are kept one more retention period.
- ADR 0014 and 0017: move keys become
R moves/...; a drain still names a node, and calls carry the app. - ADR 0020: ownership records and lease markers move under R; node
leases (
nodes/) stay shared by the prefix, and fencing stays app-blind. - ADR 0016: only that the app’s state root is a second prefix beside
apps/<t>/<a>/; secrets and sealing are unchanged. - ADR 0021: the one-app-per-tenant-per-prefix refusal is removed.
Consequences
- Two apps never share an actor, whatever prefix they use.
- Every upgrade to 0.4.0 of a cluster with state runs one command while
stopped, adding about the time of one server-side copy of the state
(tens of seconds for a thousand actors; an estimate until measured).
Storage doubles until
--remove-old. - Lifecycle rules, credentials and backup scripts written for
actors/<t>/must move tostate/<t>/<a>/. A pre-0.4 backup is restored into an empty prefix, then migrated. Deploys from CI and secret changes are frozen during the run. - A class two pre-0.3 apps shared goes to one heir; the other app starts it empty.
Alternatives
- Two layouts for good, or a zero-copy root that keeps pre-0.4 apps on the bare keys. No migration, but every later feature that touches keys (collection, export, deleting an app) is written and simulated twice, and old binaries keep working on bare data. Rejected, as in ADR 0021.
- Online migration while nodes run. It needs reads from both layouts, ownership valid in two places under one fence, and sweeps over both: the two-layout cost again. Upgrades already stop every node.
- Automatic migration at node start. A rewrite of the whole bucket should show its plan first, and some heirs need a person to choose.
- Reverse migration after 0.4.0 has written. It has its own crash
windows and doubles the work;
--rollbackbefore the first start and the bare data kept until--remove-oldcover the realistic mistakes. - Fencing live nodes from migrate. A fenced node under a service
manager restarts, and a wrong
--bucketwould fence production. It refuses live nodes instead.