Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. 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.
  2. Owns a private SQLite database. Nothing else can read or write it. Committed writes survive restarts.
  3. 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.
  4. Is sandboxed and limited. It can only use what the wasmkeep:actor interface grants: its database, alarms, calls to other actors, WebSockets, outbound HTTP, clocks, randomness, and its configuration and secrets (the env interface). 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 caseOne actor per…Uses
Chat, presence, collaborative editingroom or documentWebSockets, history in SQLite
Multiplayer game lobbiesmatchWebSockets, alarms for timeouts
Rate limiting and quotasAPI key or useralarms to reset windows
Shopping carts, sessions, workflowsuser or orderdurable state, alarms for reminders
Per-user AI agentsuser or conversationdurable memory, outbound HTTP
Device twins (IoT)devicelatest state, alarms for missed heartbeats
Customer plugins in a SaaScustomer or pluginsandboxing, 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:http components 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 needWith stateless servers, Postgres and ValkeyWith actors on WasmKeep
A chat room or game lobby over WebSocketsSticky sessions, or Valkey pub/sub to reach whichever server holds each socket; presence kept somewhere sharedThe 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 contentionRow locks (SELECT ... FOR UPDATE), retries on conflicts, or Lua scripts in ValkeyOne 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 itAn 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 codeA Wasm sandbox with CPU and memory limits, and a database per actor
Lots of small, independent stateOne large shared database every request contends on, sized for the peakMany small SQLite databases, each next to its code; an idle actor costs disk, not memory
Running itA database to operate, back up and fail over, plus the cache and the app serversOne 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-durable for 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/workflows and examples/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-outbound opens its range, and --outbound-ca trusts 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.call for 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.
  • 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 serve cannot 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.fetch blocks 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 plain exec answers 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=bucket makes 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 exec answers 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)", &params![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:

  • Actor holds the lifecycle hooks (on_init, on_alarm, and on_websocket_open, on_websocket_message, on_websocket_close). All are optional; by default an actor refuses WebSockets.
  • http::Guest is the request handler (standard wasi: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 home run one after another, in arrival order, so INSERT then SELECT can never interleave with another request to home. 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 env interface; 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-limiter uses 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::call lets 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.

TierMeaning
RecommendedSDK, tutorial, examples passing the shared tests, and benchmark rows close to the best measured; a small component with no embedded runtime.
SupportedSDK; its counter and chat room pass the shared tests in CI with pinned tools; trade-offs stated with numbers from the bench files.
ExperimentalA 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 yetCannot produce a working actor today; the entry says what blocks it.

Today

LanguageTierHow it becomes a componentWhy this tier
RustRecommendedcargo build --target wasm32-wasip2, wasmkeep-sdkSDK, examples, tutorial; 81 KB; 104 µs of CPU per request, 250 KB per resident actor (measured); no adapter (WASI 0.2 directly)
ZigSupportedZig SDK over the C SDK; the WASI 0.1 adapterCounter, 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++SupportedC SDK over wit-bindgen c; clang + wasi-libc, the WASI 0.1 adapterCounter, 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 / JavaScriptSupported (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)
PythonSupported (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)SupportedGo SDK; TinyGo (0.39 in CI, 0.43 tried by hand) -target=wasip2, which writes a component directlyCounter, 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)Experimentalcomponentize-go 0.5.1 with Go 1.27, straight on its bindingsThe 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)
DExperimentalD SDK over wit-bindgen d; LDC (1.43 in CI) with -betterC, clang + wasi-libc, the WASI 0.1 adapterCounter and chat room pass; 88 KB. No numbers from bench/run.sh yet, no outbound HTTP or key-value and queue clients (see D)
MoonBitExperimentalMoonBit SDK over wit-bindgen moonbit; moon build --target wasm (MoonBit 0.10.20 in CI), then wasm-tools component embed --encoding utf16 and component newCounter 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# / .NETExperimental (spike)componentize-dotnet 0.8.0-preview with NativeAOT-LLVM, straight on its bindingsThe 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.

LanguageGet itBuild an actor
Rustcargo add wasmkeep-sdk (crates.io)cargo build --release --target wasm32-wasip2 (tutorial)
TypeScript / JavaScriptnpm install @wasmkeep/sdk (npm)npx wasmkeep-ts build src/index.ts -o actor.wasm (README)
Pythonpip 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 releaseThe build command in the archive’s README (C SDK)
Zigwasmkeep-zig-sdk-0.3.0.tar.gz, a file of the releaseThe build command in the archive’s README (Zig SDK)
D, MoonBit, C#, standard GoThe repository’s source at the release’s tagEach 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.

  1. 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

LanguageWhat blocks itWhat would unblock it
KotlinKotlin/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 WasmJetBrains shipping WASI 0.2 and component-model support (on their roadmap), and WasmKeep enabling Wasm GC
JavaNo maintained path to a component: TeaVM removed its WASI backend, wit-bindgen removed its Java generator, GraalVM’s Wasm output targets browsers and NodeA maintained compiler and bindings generator
Scala, OCamlCompile to garbage-collected Wasm; WasmKeep’s Wasmtime is built without Wasm GC, and the component model cannot pass GC data yetEnabling Wasm GC in WasmKeep (its own decision: an ADR, benchmarks, pool sizing)
SwiftStable WASI 0.1 SDK, but no WIT bindings generator for importsA Swift wit-bindgen backend
AssemblyScriptIts maintainers reject the component model—
RubyNo bindings generator; very large runtimeA componentize-rb
PHPThe 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 hooksA componentize-php (the interpreter as a library, wit-dylib glue, pre-initialization), the route componentize-py takes for Python
Grain, CrystalNo component supportUpstream work
Lua (via wit-dylib)Cannot export interface functions yet (our world needs incoming-handler), and needs Wasm exceptionsUpstream wit-dylib progress, Wasm GC/exceptions in WasmKeep
JavaScript via JavyProduces core modules, not componentsUse 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):

LanguageComponent
Zig30 KB
MoonBit49 KB
Rust81 KB
D88 KB
Go (TinyGo)162 KB
C170 KB
C#929 KB (NativeAOT, debug sections stripped)
TypeScript1.7 MB (QuickJS engine, the default)
Go (standard)2.7 MB (componentize-go; Go’s runtime)
TypeScript on StarlingMonkey13.4 MB (StarlingMonkey JS engine)
Python18.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, setTimeout or structuredClone; StarlingMonkey has all of these. Neither has Intl. 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 test typeof fetch still pick their fallback. QuickJS does have atob, btoa, queueMicrotask, performance and WeakRef. For outbound HTTP on either engine, the SDK has http.fetch: synchronous, as handlers are (StarlingMonkey’s own fetch returns a promise a handler cannot await).
  • console comes 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).
  • TextEncoder and TextDecoder come from the SDK, UTF-8 only and without streaming decode. The SDK installs them when the engine lacks them, so the same code runs on both engines.
  • A WIT none is null, not undefined. The SDK’s env.get and alarms.get return undefined on both engines; code that imports wasmkeep:* or wasi:* interfaces directly sees null.
  • 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: execDurable from TypeScript on either engine (actors.call is, 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 fmt or 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: SetAlarm and the Alarm hook, ExecDurable and DeleteSelf from Go (Call is, 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 bindings generates Go packages for every interface of crates/wasmkeep-wit/wit, and the app implements two packages it names: export_wit_world (the five hooks, returning witTypes.Result[witTypes.Unit, string]) and export_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 build for wasip1 builds here.

What is missing:

  • An SDK. componentize-go’s bindings (go.bytecodealliance.org/pkg) are not the wit-bindgen-go ones the Go SDK wraps for TinyGo, so the SDK does not build on standard Go yet. Its kv, queue and workflow clients 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.sh for CPU or memory per actor yet.
  • Rough edges. The module must be named wit_component (the bindings import it by that path), bindings rewrites go.mod from its own template on every run (the example’s build.sh keeps the committed one), the build needs -buildvcs=false inside 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-sharp on crates/wasmkeep-wit/wit at every build; the app implements two generated interfaces (IActorWorldExports for the five hooks, IIncomingHandlerExports for requests) and calls the imports as static methods (IStorageImports.Exec, …).
  • One build command. dotnet build restores 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 throwing WitException<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.csproj names the Linux x64 one.
  • An older filesystem import. The component imports wasi:filesystem@0.2.6 where the world has 0.2.12, so wasm-tools component targets rejects 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 from bench/run.sh come before a tier above experimental.
  • Size. 2.5 MB as built, 929 KB with its debug sections stripped (wasm-tools strip --all, which build.sh runs): 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.

SDKServerWIT package
0.3.00.3.0wasmkeep:actor@0.2.0
0.2.00.2.0wasmkeep:actor@0.2.0
0.1.00.1.0wasmkeep: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 from bench/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 --actor serves the tenant default; --app acme/shop serves app shop of tenant acme. 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 Unavailable with Retry-After: 1: retry. A fault a retry will not fix (an unreadable ownership record, a history that cannot be restored) is a 500.
  • 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.call receive 503 Service Unavailable with Retry-After: 1 before 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 with quota-exceeded and 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-init runs 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, then ALTER TABLE guarded by a version you keep in a table). If it fails, the event fails and on-init is 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-self removes 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.

  • exec runs a statement and returns the rows changed; query returns rows. One statement per call, with ? parameters.
  • Outside a transaction each statement commits on its own. BEGIN … COMMIT across several calls is allowed within one event; a transaction still open when the event ends is rolled back.
  • exec returns once the write is committed locally. exec-durable returns 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-durable takes only the COMMIT (or END): run the transaction’s statements with exec, then exec-durable("COMMIT") makes the whole transaction durable. Any other statement there is refused with not-allowed before 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-exceeded and reads still work.
  • Values (text and blobs) are capped at 16 MB, and so is everything one query returns; 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-exceeded until 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, no TEMP tables, views or triggers (they would escape the size cap). Virtual tables: full-text search (fts3, fts4, fts5) and rtree only.

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-alarm is 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-alarm returns 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 1 arrives as room%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 reentrant instead 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-continue straight 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-open may 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.
  • send queues a message; a client that does not keep up gets backpressure (at 256 messages or 4 MiB queued) and that message is dropped for it.
  • connections lists 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. --env for a class that is not served is an error (a typo would otherwise leave a class without its token).
  • On the node. --env values 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-init does 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 optional export , 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 (see wasmkeep_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 carry x-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 (SIGTERM or 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 heartbeat released, 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: 1 when 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 GET that writes. A kept GET runs up to 25 s after it was sent, and its client may have retried elsewhere meanwhile. An actor whose GET handlers 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 by wasmkeep_snapshot_upload_failures_total (monitoring).
  • storage.exec commits locally; its upload follows when the event ends. If the node dies in between, that write can be lost. storage.exec-durable returns 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 with Retry-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, and wasmkeep_restore_failures_total counts it (monitoring).
  • storage.delete-self is recorded in the bucket, so a deleted actor stays deleted wherever it runs next. Like a plain exec, 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-durable takes only the COMMIT: it returns once the whole transaction is in the bucket. Any other statement there is refused before it runs, since nothing is durable before COMMIT.
  • 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.replaced rather than deleted. Only one generation is kept: a second replacement of the same actor’s file overwrites the first .replaced copy.

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

FlagMeaning
--node-idThis node’s id, [a-z0-9-], unique in the cluster (see below)
--advertiseURL the other nodes use to reach this one
--buckets3://BUCKET[/PREFIX]; several clusters can share a bucket under different prefixes
--cluster-listenAddress 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-advertisehttps://HOST:PORT the other nodes reach the cluster listener at
--cluster-plaintextrefuse (default) or accept: whether plain HTTP between nodes is still used (see below). With refuse, a node needs --cluster-listen
--failover-wait-secsHow 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-secsHow 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 FILEWith --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-secretsWith --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 serve listens (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 serve shuts 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 serve exits 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-advertise must be https://. 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. --listen still 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 accept mode: 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 --listen is treated as a client’s (access rules apply, no calling actor, internal classes refused), and peers without a cluster listener are not used: requests and WebSockets for actors they own get a 503 from this node and actors.call to them fails (it logs which peers it skips, once a minute). Clients are served on --listen as 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 with wasmkeep cluster status that 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 (internal classes refuse them, callees see no caller). Going back is the reverse: add accept everywhere 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.call between 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 in crates/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.call is 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 deploy of an app refuses when another app of its tenant is deployed on the prefix (it has apps/<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), and deploy and serve warn about them by name; a further one is refused while any of them has a current. 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 no app delete yet. To free the tenant from an app nobody runs, stop its nodes and delete its current, 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>/ and alarms/<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.

  • deploy compiles 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-init runs 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 deploy itself, 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-secs ago (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 of previous links are candidates, oldest first, and the pass stops if current changes 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:

  1. Copy the new file to every node, at the path its serve command names.
  2. Restart the nodes one at a time (systemctl restart wasmkeep), waiting for each to be listed live by wasmkeep cluster status and to answer /_wasmkeep/ready before 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/ready takes 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 serve exit 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=open removes a rule, --config CLASS= a class’s configuration. deploy prints 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).

  • --config is stored in the clear, as parsed (no comments). Keep secrets in serve --env files on each node: they win over the deployed configuration by name (each overlap is logged by name, never value). deploy warns 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 (existing to open, say) is refused unless --allow-reopen is given.

  • New values drop the class’s resident instances at their next event (the database is kept, on-init does not rerun); rules alone do not. A class made internal closes 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.

  • --access with --app is refused once rules have been deployed (the start-up error gives the wasmkeep deploy command). Before that it still works, so a cluster started with --access moves in three steps:

    1. upgrade every node, flags unchanged;
    2. deploy the rules once: wasmkeep deploy --app acme/shop --bucket ... --access monitor=existing (nodes switch to them and log that --access is now ignored);
    3. remove --access from 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-init does 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). --env stays a node-local break-glass.
  • Several at once: set --file FILE (entries <class> <name> <length>\n<value>\n, any value, newlines included) and unset --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, current unreadable). Past those windows (one poll, 3 s, or the 30 s) it never runs an actor with an older or empty set. cluster status then shows it draining or drained without a drain marker; undrain does 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 current below 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). Deleting current by hand leaves running nodes with the set they have and restarted ones with none.
  • Older nodes. set, unset and reset refuse, 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, as cluster status does.
  • Changing the key. Give every node the new key as well (a second --secrets-key, one node restarted at a time), run wasmkeep 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. set and unset given 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-key writes a whole set without opening the current one.
  • Moving from --env. Upgrade every node, give them the key, set each name, then remove the names from the --env files and restart the nodes one at a time.
  • Required values. wasmkeep deploy --requires hub=ADMIN_TOKEN,WEBHOOK_URL makes 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) or stuck (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 written fenced (a peer took its lease) or released (a clean stop) is shown as no (fenced) or no (released) whatever its age, and as lease in --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 its seq. 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_ms in --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 fence stops 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-close events do not run, since the draining node does not take the room back to run them;
  • reports drained once 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):

  1. its WebSockets close with 1012 and their close events run;
  2. its queued events finish and are uploaded;
  3. 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;
  4. node-c takes 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.

  1. wasmkeep cluster status --bucket ...: note the nodes it lists as live.
  2. 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.
  3. Wait until cluster status exits 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.
  4. Install the same new binary on every node.
  5. Start every node, then run cluster status again: 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 internal classes on the other kind fail, and a call cycle across them ends in a timeout instead of reentrant.
  • 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, by cluster 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:

  1. stops accepting connections, so a load balancer’s check of /_wasmkeep/ready fails from then on;
  2. lets requests under way finish, for up to 10 s;
  3. closes its WebSockets with code 1012;
  4. 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;
  5. 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:

CallerGets
An HTTP request that reached the dead nodeA broken connection, or none: the load balancer’s check of /_wasmkeep/ready takes the node out
An HTTP request that reached another node503 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 actorThe 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 upgradeThe same 503 with Retry-After: 1
An open WebSocketNo 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 with wasmkeep 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 (&str works). 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 same n, 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) serves get from memory in the caller’s instance for up to ms; the caller’s own writes through that client update it; get_fresh skips 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::shard from your handler instead of exporting Shard.

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/QueueError kind refused, status 200) instead of being rounded.
  • Errors are exceptions: KvError (kind: conflict, key-too-long, storage, call, refused, with status for refused) and QueueError (storage, call, refused), with the Rust messages. A negative or fractional count, delay or limit throws a RangeError before 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 as Uint8Array.
  • A throwing delivery handler makes fetch answer 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 an int has no bounds, a count, delay, limit or version outside what Rust’s u32 or u64 would hold raises ValueError before any call.
  • Errors are exceptions: KvError (kind: conflict, key-too-long, storage, call, refused, with status for refused) and QueueError (storage, call, refused), with the Rust messages as str(e).
  • Options are keyword arguments: put_with(key, value, expect=, ttl_ms=, durable=), where expect is "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" or queue.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) or bytes; values come back as bytes.
  • A raising delivery handler makes fetch answer 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, with Status for Refused) and *queue.Error (Storage, Call, Refused), with the Rust messages. errors.Is(err, kv.ErrConflict) matches by kind; errors.As gives the fields.
  • Zero values are the defaults. kv.PutOptions{} is a durable, unconditional write without expiry: Local: true asks for a local write (Rust’s PutOptions::local()), and TTLMs: 0 means no expiry (so a zero TTL cannot be asked for). The zero kv.Expect is kv.Any(), the zero queue.Outcome is queue.Ack().
  • Keys are strings (any bytes, so the same bytes reach the same shard from every SDK); values and bodies are []byte. List and ListAfter replace Rust’s Option for after.
  • The delivery helper does not touch HTTP: it takes the x-wasmkeep-caller header ("" 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’s wasip2 target, and their unit tests run with plain go 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 optional wk_kv_error / wk_queue_error with a kind (WK_KV_CONFLICT, WK_KV_KEY_TOO_LONG, WK_KV_STORAGE, WK_KV_CALL, WK_KV_REFUSED with status; 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 _free function; returned bytes are NUL-terminated as well. Clients borrow their class name or address. wk_queue_send_batch and wk_queue_settle write into arrays the caller sizes; settle marks items the queue’s reply leaves out as not current.
  • Keys and values are wk_bytes (wk_str for a C string), 64-bit integers are uint64_t, and options are optional pointers (NULL: an absent key for compare_and_set, the queue’s backoff for retry).
  • A delivery handler returns false to 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) and wk.queue.Error (Storage, Call, Refused); the message and status of the last failure are wk.kv.lastError() / lastStatus() (and the same in wk.queue).
  • Ownership: entries, key pages, leased messages and dead letters have deinit; sendBatch and settle take an allocator for the slice they return.
  • A delivery handler returns an Outcome, or an error union of one: an error makes handleDelivery return it, so fetch answers 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, delete and send return 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::Call 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.
  • 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 id and a higher attempt. 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 403 from shards and queues (they answer only actors.call). Serve their classes with --access CLASS=internal as 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) calls f and, once it returns Ok, records its result (String, Vec<u8>, u64, i64, bool or (): convert other types to one of these, or implement Stored); every later run gets the recorded result without calling f. Until then f may run again: an error from f is 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, with try_step, gets the error to handle.
  • sleep(name, ms) and sleep_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 with Client::send_event, and returns its payload, or None after 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 own Stop (waiting) through.

Two rules

  1. 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.
  2. A step may run more than once. Its result is recorded after f returns, so a node failure or a cut in between runs it again. Make steps idempotent, or use step_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")?;
}
  • start only 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.
  • status reports Running, Waiting (until when, for which events), Completed (with the output), Failed (with the error) or Cancelled, and how many positions are recorded.
  • send_event and cancel to an id that was never started answer Error::Unknown and leave nothing behind; an event to a finished instance answers Error::Finished.
  • cancel stops 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 start with 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:

  • Status is a frozen dataclass: state is a string ("running", "waiting", "completed", "failed" or "cancelled", also in workflow.STATES), wake_at an int or None, waiting_for a list of names, output bytes or None, error a string or None, positions an int; is_finished is a property.
  • Errors are exceptions: WorkflowError with kind unknown (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) or refused (any other answer, with status), worded as Rust’s Display.
  • Inputs and payloads are str (sent as UTF-8) or bytes, 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 false on failure and fills an optional wk_workflow_error: WK_WORKFLOW_UNKNOWN, WK_WORKFLOW_FINISHED, WK_WORKFLOW_CALL (the call failed; the instance may or may not have recorded it) or WK_WORKFLOW_REFUSED with status (200 for a malformed status), and the Rust message.
  • wk_workflow_get_status is Rust’s status (wk_workflow_status is the type). Ids, event names and payloads are wk_bytes; a status is freed with wk_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_ms on 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.tryStep returns { ok } or { error, attempts } instead of failing the workflow.
  • wf.sleep, wf.sleepUntil and wf.waitForEvent (the payload as bytes, or undefined after the timeout) work as in Rust.
  • new workflow.Client("orders") has start, status, sendEvent and cancel; errors are workflow.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 its Content-Length or inside a chunk, a reset, a timeout between bytes) throws a FetchError naming 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 than limit is not an error: its first limit bytes are kept. The Rust SDK’s http::fetch does 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.

OptionProsConsUse it for
Dedicated server (bare metal)Best price per performance; local NVMe makes fsync fast and predictable; no noisy neighboursYou handle hardware failure and OS upkeep; slower to resizeProduction on a budget, clusters of three or more, benchmarks you publish
Virtual machineEasy to create, resize and snapshot; private networks built inNoisy neighbours (our shared-VM benchmark runs vary by up to ±30%); network-attached disks add fsync latency: prefer instance-local NVMe or fast volumesThe 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 belowTeams that standardise on containers
Serverless containers (Cloud Run, Fargate, Lambda)No servers to manageScale-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 modelNot 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 and fsync).
  • Open files: give the process a high limit (LimitNOFILE=65536 with systemd, --ulimit nofile=65536 with Docker). WasmKeep raises its soft limit to the hard one and caps resident actors to fit (monitoring).
  • Virtual memory: no LimitAS or ulimit -v; do not use MemoryDenyWriteExecute (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-id per 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 .wasm and systemctl restart wasmkeep. Each actor runs on-init again 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/shop switch 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).

ProviderSettingsCredentials
Cloudflare R2 (tested)AWS_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com, AWS_REGION=autoAn 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 S3AWS_REGION=<the bucket's region>; no endpointAn 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 networkA 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 watches nodes/<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-listen and --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 --actor flags on every node or, better, --app to 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 accept and no listeners: add the two flags (keep accept for 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 status shows a cluster address and a pin for every live node, and no warning about plain nodes): remove --cluster-plaintext accept and 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, add accept to every node before removing any listener.

  • Upgrading WasmKeep (the binary, not your app): upgrade every node together, not one at a time. systemctl stop wasmkeep on all three, wait until wasmkeep cluster status --bucket s3://acme-wasmkeep/prod exits with 1 (no live node, about 10 s after the last stop), install the new binary on all three, start them, and check cluster status again: 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 serve for 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 --env files. 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 with wasmkeep 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 b
    

    A 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, as systemctl stop sends): 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=30 in systemd, --stop-timeout 30 for Docker (its default is 10 s), terminationGracePeriodSeconds: 30 in 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-durable are in the bucket, a plain exec’s last event may be lost (see Running a cluster).

  • Logs: each node logs to the journal; RUST_LOG=wasmkeep_server=debug shows 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, and wasmkeep deploy keeps 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: 503 while a node shuts down, cannot reach the bucket or is nearly out of disk (monitoring). /_wasmkeep/health only 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, a HEAD or 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.

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.

SetupProsCons
DNS round robin, Caddy on every nodeNo extra machine; automatic certificates; the simplestA 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 nodesNo extra machine; failover in seconds; health checks and routing by actorOne node takes all incoming traffic at a time; keepalived to set up
A managed load balancer (Hetzner, DigitalOcean, AWS…)Nothing to run; health checksA small monthly cost; some cannot hash on a path prefix
An outbound tunnel, HAProxy on the nodesNo inbound port and no public address on the nodes; TLS ends at the providerEvery 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 ssl on the bind line.
  • 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 forwardfor would write that. The client’s address is in whatever header the provider sets: read that one in your actors. The frontend deletes X-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:

KeysWhatWritten
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 writesCreated once, never changed
owners/<tenant>/<class>/<name>Which node holds the actor, with a generation that grows by one at every holdReplaced when the actor changes hands
nodes/<id>Each node’s heartbeat, which is its leaseReplaced every 3 s
alarms/<tenant>/...The index of pending alarms, by due timeCreated and deleted as alarms change
apps/<tenant>/<app>/<version>/ and apps/<tenant>/<app>/currentDeployed versions (components, access rules, configuration) and the pointer to the current oneVersions created once; current replaced by a deploy
apps/<tenant>/<app>/secrets/The app’s secrets, sealed, and their own currentAs above
cluster/secretThe secret nodes sign their calls to each other withCreated once
control/Drain and move requestsBy 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 writesDeleted 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 --env files, the stateless component if you serve one, and the serve command lines (the systemd units);
  • with --actor instead 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-self erases 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

  1. Stop every node and wait until wasmkeep cluster status exits 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).

  2. 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/prod
    

    Keep the prefix’s name (prod here) 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 with wasmkeep secrets reset.

  3. 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.

  4. Start the nodes with --bucket naming the restored prefix, and with the same node ids as before.

  5. Check: wasmkeep cluster status lists every node live, and the deployed version and seq of 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/.../current names 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 say live and 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 says live), which frees the records naming its old process. An id nobody starts again is fenced by the other nodes, or with wasmkeep 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:

  1. Copy last night’s backup into an empty prefix of a scratch bucket, with the same prefix name.
  2. 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.
  3. Request a sample of actors, including the largest databases and some that had not been written for a long time. A 500 is an actor that did not restore: look at the node’s log for the missing object.
  4. Compare what they answer with what you expect at the time of the backup.
  5. 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

PathAnswers
GET /_wasmkeep/health200 ok while the process accepts requests.
GET /_wasmkeep/ready200 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:

ReasonWhat it found
another process runs with this node idanother process writes this node id
this node was fenceda peer fenced its lease after it stopped renewing, or an operator ran wasmkeep cluster fence
this node's lease was released by another processanother process stopped cleanly under this id
this node's lease object was deletedits nodes/<id> object was removed
an older version of WasmKeep runs with this node ida heartbeat in the format from before node leases under this id
a node of an older WasmKeep version runs in this clusteranother node of a version from before node leases (stop every older node first)
this node's lease object holds something this version cannot readan unreadable object under this id
the bucket does not enforce conditional writesthe 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.

MetricTypeWhat it tells you
wasmkeep_actors_residentgaugeActors in memory now: capacity planning.
wasmkeep_websockets_opengaugeRegistered WebSockets, including pending close cleanup.
wasmkeep_classesgaugeClasses served.
wasmkeep_ready, wasmkeep_not_ready{reason}gauges1 when /_wasmkeep/ready answers 200; otherwise one wasmkeep_not_ready series saying why.
wasmkeep_events_total{class,kind,outcome}counterEvents 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}histogramSlow handlers and hot classes.
wasmkeep_event_cuts_total{reason}counterEvents the host stopped: wall (past the wall-clock limit) or lease (this node lost the actor mid-event).
wasmkeep_cpu_budget_exceeded_totalcounterGuest calls stopped for using their CPU budget.
wasmkeep_instantiate_secondshistogramCreating an actor’s instance: the wake cost.
wasmkeep_wakes_total, wasmkeep_hibernations_totalcountersActor 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}counterRequests (request), sockets (websocket) and calls (call) sent to the node owning the actor: placement efficiency.
wasmkeep_failover_waits_total{kind,outcome}counterRequests (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_secondshistogramexec-durable until durable: the bucket’s health as actors feel it.
wasmkeep_requests_abandoned_totalcounterRequests 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_secondshistogramOne 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_totalcounterRestores 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_secondshistogramOne 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_totalcountersLines actors printed that reached the log, and that were dropped over the rate (what actors print).
wasmkeep_admission_refused_total{reason}counterRequests, 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_totalcounterAlarm deliveries rescheduled because resident capacity or an actor queue was full; no actor labels.
wasmkeep_node_lease_writes_total, wasmkeep_node_lease_failures_totalcountersWrites 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_totalcounterFences this node wrote over a node that stopped renewing its lease (crashed, or cut off from the bucket).
wasmkeep_bucket_requests_total{operation,status}counterRequests this node made to the bucket: cost and errors. See below.
wasmkeep_failovers_totalcounterActors 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_totalcounterObjects of actors’ history deleted from the bucket by garbage collection (ADR 0012).
wasmkeep_history_collect_failures_totalcounterGarbage-collection passes that failed (a bucket error); the next snapshot tries again. Steadily rising: the bucket refuses deletes.
wasmkeep_snapshot_upload_failures_totalcounterSnapshot 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}counterCalls 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}counterWebSockets 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_totalcounterOwnership 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:

StatusMeans
okThe request succeeded.
not_foundThe object did not exist. Routine: a lease or an app not created yet.
preconditionA conditional write or read lost its condition (the object exists, or changed since it was read). Routine for leases: another node won the race.
errorAnything 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.

RowPanels
OverviewNodes ready and not ready (with the reason), resident actors, open WebSockets, failovers over the time range.
EventsEvents 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 placementWakes and hibernations, instantiation p50 and p99, forwards by kind, alarm admission retries.
Bucket and leasesBucket 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_total increasing 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/ready failing 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-rate lines 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 0 discards 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=warn to keep only the drop warnings, or wasmkeep::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.

RustTypeScriptPythonCZig
Actor::on_initinit()Actor.initwk_initpub fn init

on-alarm

on-alarm: func() -> result<_, string>;

Runs when the pending alarm is due. An error or trap schedules a retry.

RustTypeScriptPythonCZig
Actor::on_alarmalarm()Actor.alarmwk_alarmpub 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.

RustTypeScriptPythonCZig
Actor::on_websocket_openwebsocket.open()Actor.websocket_openwk_ws_openpub fn wsOpen

on-websocket-message

on-websocket-message: func(conn: u64, msg: message);

A message arrived on one of this actor’s connections.

RustTypeScriptPythonCZig
Actor::on_websocket_messagewebsocket.message()Actor.websocket_messagewk_ws_messagepub 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).

RustTypeScriptPythonCZig
Actor::on_websocket_closewebsocket.close()Actor.websocket_closewk_ws_closepub 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.

RustTypeScriptPythonCZig
storage::execstorage.execstorage.execwk_execwk.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.

RustTypeScriptPythonCZig
storage::exec_durablestorage.execDurablestorage.exec_durablewk_exec_durablewk.execDurable

query

query: func(sql: string, params: list<value>) -> result<rows, sql-error>;

Runs a statement and returns its rows.

RustTypeScriptPythonCZig
storage::querystorage.querystorage.querywk_querywk.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.

RustTypeScriptPythonCZig
storage::delete_selfstorage.deleteSelfstorage.delete_selfwk_delete_selfwk.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.

RustTypeScriptPythonCZig
alarms::set_alarmalarms.setalarms.setwk_alarm_setwk.setAlarm

get-alarm

get-alarm: func() -> option<u64>;

The pending alarm time, if any.

RustTypeScriptPythonCZig
alarms::get_alarmalarms.getalarms.getwk_alarm_getwk.getAlarm

clear-alarm

clear-alarm: func();

Cancels the pending alarm, if any.

RustTypeScriptPythonCZig
alarms::clear_alarmalarms.clearalarms.clearwk_alarm_clearwk.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: stringPath 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.
reentrantThe call would re-enter an actor already waiting in this call chain (including the caller itself), which would deadlock.
too-largeThe request or response exceeded the body limit (16 MB).
timeoutThe 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.

RustTypeScriptPythonCZig
actors::callactors.callactors.callwk_callwk.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.

RustTypeScriptPythonCZig
actors::self_addressactors.self()actors.self_addresswk_self_addresswk.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-connectionThe connection is closed, or belongs to another actor.
backpressureThe 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.

RustTypeScriptPythonCZig
websockets::sendsockets.sendsockets.sendwk_send, wk_send_binarywk.send, wk.sendBinary

close

close: func(conn: u64, code: u16, reason: string);

Closes the connection with a close code (1000 = normal) and reason.

RustTypeScriptPythonCZig
websockets::closesockets.closesockets.closewk_closewk.close

connections

connections: func() -> list<u64>;

This actor’s open connections, oldest first.

RustTypeScriptPythonCZig
websockets::connectionssockets.connectionssockets.connectionswk_connectionswk.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.

RustTypeScriptPythonCZig
env::getenv.getenv.getwk_envwk.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
syntaxThe SQL did not parse, or referred to a missing table or column.
constraintA constraint failed (UNIQUE, NOT NULL, CHECK, FOREIGN KEY).
bindParameter count or types do not match the statement.
multiple-statementsThe text held more than one statement; send one at a time.
quota-exceededThe database reached its size cap (default 1 GB); reads still work.
not-allowedThe statement tried something actors may not do (ATTACH, a PRAGMA outside the allowed set, loading extensions).
internalThe host failed (disk, I/O). The event should fail.

sql-error (record)

Field
kind: sql-error-kind
message: stringHuman-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.

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.

CounterWasmKeep, RustWasmKeep, TypeScriptworkerd
Read, resident actor (p50 / p99)61 / 87 µs139 / 269 µs156 / 212 µs
Write, resident actor (p50 / p99)67 / 92 µs146 / 262 µs177 / 225 µs
First request to a new actor (p50)0.49 ms0.77 ms0.41 ms
Memory per resident actor250 KB741 KB277 KB
Server CPU time per request (8 clients)104 µs230 µs128 µs
Throughput, 8 clients, 1000 actors61,963 req/s29,712 req/s7,681 req/s*
Process start to first response51 ms202 ms51 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 VMWasmKeep, Rustworkerd
Server CPU time per request215 µs555 µs
Memory per resident actor251 KB267 KB
Read, resident actor (p50)213 µs411 µ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 cd48f41 to dec1a82), for every native language alike: this machine’s kernel has transparent huge pages set to always, so each actor’s memory was backed by 2 MiB pages it mostly did not use. wasmkeep serve now turns them off for its own process (--transparent-huge-pages system keeps 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_work load 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 exec commits 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/counter built with its durable feature, so POST commits with exec-durable and 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. Its POST answers 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 with celld deploy to 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 tmpfsWasmKeep, durablecelldWasmKeep, plain exec (not equivalent)
Write, resident actor, p500.53 ms (0.53–0.54)0.93 ms (0.93–0.94)0.46 ms (0.46–0.46)
Write, p991.15 ms (1.12–1.24)1.25 ms (1.24–1.29)1.33 ms (1.16–1.49)
Read, resident actor, p5064 µs (61–71)117 µs (114–128)64 µs (59–66)
First request to a new actor, p503.10 ms (3.08–3.13)2.99 ms (2.95–3.05)2.62 ms (2.59–2.64)
Memory per resident actor272 KB (272–274)293 KB (292–294)247 KB (247–248)
Server memory, 1000 resident actors333 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 actors10,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 response15.08 s*79 ms (77–79)15.08 s*
Counter on a bucket, data on diskWasmKeep, durablecelldWasmKeep, plain exec (not equivalent)
Write, resident actor, p502.31 ms (2.23–2.32)2.55 ms (2.49–2.57)0.54 ms (0.53–0.54)
Write, p996.02 ms (5.95–6.14)3.64 ms (3.20–4.26)5.81 ms (5.78–5.84)
Read, resident actor, p5063 µs (62–66)116 µs (113–130)64 µs (60–67)
First request to a new actor, p5019.4 ms (19.3–19.6)17.1 ms (16.9–17.2)16.9 ms (16.6–16.9)
Memory per resident actor273 KB (273–274)289 KB (288–290)247 KB (246–248)
Server memory, 1000 resident actors334 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 actors1,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 response15.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-durable in 2026-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 exec on 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/): plain exec on 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 eachWasmKeep, durablecelld, fleetcelld, bucketWasmKeep, plain exec (may lose, by design)
Acknowledged writes checked (data on disk)about 71,700about 41,700about 60,500about 86,400
Acknowledged writes lost0 in every run0 in every run0 in every run0 to 3 a run
Lost, the killed node’s disk removed0 in every run0 in every run0 in every runnot run
Writes applied twice0000
Ledgers that could not be read back0000
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 diskWasmKeep, durablecelld, fleetcelld, bucketWasmKeep, plain exec (not equivalent)
Write, through a random node, p502.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, p995.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, p5092 µs (91–93)218 µs (215–224)220 µs (211–221)93 µs (91–96)
First request to a new actor, p5019.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 actors459 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 actors1,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, p5015.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 actor16.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)90909090 to 91
Restarted node ready (at most)1.56 s0.15 s (0.14–0.18)0.13 s (0.13–0.19)1.56 s
Cluster start to first response15.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 tmpfsWasmKeep, durablecelld, fleetcelld, bucketWasmKeep, plain exec (not equivalent)
Write, through a random node, p500.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, p991.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, p5095 µs (92–96)217 µs (213–226)218 µs (215–231)94 µs (93–97)
First request to a new actor, p503.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 actors460 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 actors9,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, p5015.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.

CounterRustZigCGo (TinyGo)TypeScriptTS on StarlingMonkeyPython
Component81 KB30 KB170 KB151 KB1.7 MB13.4 MB18.7 MB
Server CPU per request (8 clients)104 µs107 µs110 µs248 µs230 µs368 µs259 µs
Throughput, 8 clients61,963 req/s59,649 req/s58,840 req/s28,160 req/s29,712 req/s19,708 req/s26,751 req/s
Read, resident actor (p50 / p99)61 / 87 µs72 / 116 µs65 / 114 µs73 / 742 µs139 / 269 µs227 / 334 µs142 / 212 µs
Write, resident actor (p50 / p99)67 / 92 µs76 / 111 µs72 / 124 µs85 / 3,375 µs146 / 262 µs241 / 329 µs151 / 214 µs
Memory per resident actor250 KB242 KB244 KB390 KB741 KB3.4 MB7.8 MB
First request to a new actor (p50)0.49 ms0.51 ms0.51 ms0.70 ms0.77 ms1.2 ms1.8 ms
Process start to first response51 ms51 ms51 ms52 ms202 ms704 ms705 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.

CounterRustTypeScript, QuickJSTypeScript, StarlingMonkey
Component81 KB1.7 MB13.4 MB
Memory per resident actor250 KB741 KB3.4 MB
Server memory, 1,000 resident actors301 MB936 MB4.17 GB
Server CPU per request (8 clients)104 µs230 µs368 µs
Throughput, 8 clients, 1,000 actors61,963 req/s29,712 req/s19,708 req/s
Plain JavaScript work, js_work (p50 / p99)5.76 / 6.23 ms3.76 / 4.21 ms
First request to a new actor (p50)0.49 ms0.77 ms1.22 ms
Process start to first response (empty compile cache)51 ms202 ms704 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, medianPlainMutual TLSTLS adds (earlier run)
Counter GET sent to the owner (no hop)63.2 µs
The same GET sent to the other node, which forwards it106.3 µs115.1 µs8.8 µs (11.8 µs)
actors.call from an actor on one node to an actor on the other133.7 µs155.1 µs21.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 writtenReplayedRestore, as writtenRestore, no periodic snapshots (replays N+1)Listing, as writtenObjects, collectedRestore, collectedListing, collected
none000.78 ms
1012111.06 ms1.06 ms1.1 µs121.06 ms1.1 µs
100103371.93 ms4.26 ms9.1 µs391.92 ms3.4 µs
1,0001,017412.56 ms35.0 ms94.8 µs432.07 ms3.7 µs
10,00010,158176.13 ms342 ms1.72 ms191.23 ms1.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 in tests/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 diskStart-up alarm scanStart to first answer from an actorStart until every overdue alarm ran
1,000108 ms3.9 ms257 ms
10,0001.06 s3.9 ms3.36 s
100,00011.5 s4.0 msnot 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 behindUntil all ran, at a tenth of the defaultsAt the defaults (estimate: ×10)
1001.92 sabout 19 s
1,0003.08 sabout 31 s
10,00023.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_DELIVER in alarm_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.rs checks 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 9c495b4 run).

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 stepsOne run (median)Over the floor
00.21 ms
100.24 ms0.03 ms
1000.37 ms0.16 ms
1,0001.72 ms1.51 ms
5,0007.74 ms7.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, desktopOVH, desktopOVH, three VPS
Acknowledged durable writes lost000
Values handed out twice (two live owners would)000
Alarms delivered72 of 7272 of 7272 of 72
Writes acknowledged in 30 minutes21,260129,577171,050
Durable write, as a client sees it (p50 / p99)587 ms / 2.0 s81 ms / 0.49 s54 ms / 0.42 s
Writable again after its node died (p50 / max)21.2 / 29.1 s16.6 / 25.9 s16.6 / 32.0 s
Alarm, after its node died (p50 / max)30.2 / 33.6 s21.2 / 33.8 s19.7 / 25.1 s
Restarted node ready3.0 s at most2.8 s at most7.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 of 82dab61 for all four. Nothing lost or duplicated, every alarm fired, in all four.

    BlockLeasesWritesDurable p50 / p99Writable again p50 / p99 / worst
    1per node144,72753 / 666 ms17.4 / 23.6 / 34.3 s
    2per actor185,01250 / 419 ms15.5 / 19.1 / 25.4 s
    3per node126,97462 / 747 ms16.7 / 26.3 / 37.6 s
    4per actor186,42750 / 367 ms15.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)Systemmimalloc
Rust102 µs99 µs
Zig105 µs102 µs
C105 µs102 µs
TypeScript (QuickJS)228 µs224 µs
Python260 µs254 µ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 musl build 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 counterNativeglibc 2.28muslmusl + mimalloc
Server CPU per request104 µs104 µs243 µs118 µs
Throughput (8 clients)61,300/s61,400/s27,400/s53,000/s
Startup to first response51 ms51 ms102 ms52 ms
Memory per resident actor249 KB250 KB265 KB264 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 node243 µ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

PartyCanCannot
Anyone who reaches the HTTP portSend requests and open WebSockets to actors of open classes (creating actors by naming them), and to existing actors of existing classesReach 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 addressesRead 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 clusterSign calls to other nodes; take leases; write the bucket(Trusted: nodes and the bucket’s owner control the cluster)
Whoever can write the bucketEverything: 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-continue whose 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-open accepts 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:actor world 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-outbound and --outbound-ca open 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-port opens 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 default serve refuses 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 fence cluster fence creates 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, internal classes 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 deploy of 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; deploy and serve --app warn), 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 and serve exits (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.get and 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. With refuse (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-listen and --cluster-advertise, and refuses plain HTTP between nodes. A node started with --cluster-plaintext accept opens 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 in accept mode 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 existing class 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 to sdks/typescript/wit/deps/, crates/wasmkeep-sdk/wit/deps/, sdks/go/wit/deps/ and sdks/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 by scripts/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 in conformance/ltx (a development tool, never shipped), whose output files are the test fixtures in crates/wasmkeep-storage/tests/ltx.
    • WIT, WASI, the component model: open standards of the W3C WebAssembly Community Group and the Bytecode Alliance.

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:

LanguageRuntime inside the componentLicense
Rustthe Rust standard libraryMIT OR Apache-2.0
TypeScript (default)QuickJS-ng, via componentize-qjsMIT (QuickJS-ng); Apache-2.0 (componentize-qjs)
TypeScript (--engine starlingmonkey)SpiderMonkey, via StarlingMonkey and ComponentizeJSMPL-2.0 (SpiderMonkey): distributing the component requires making SpiderMonkey’s source available; Apache-2.0 (StarlingMonkey, ComponentizeJS)
PythonCPython, via componentize-pyPSF License (keep its notice); Apache-2.0
C, Zigwasi-libcMIT / Apache-2.0 / CC0
Gothe TinyGo runtimeBSD-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"}
FieldMeaning
schemaFormat version of this report
versionWasmKeep’s version
os, archOperating system and CPU architecture (linux, x86_64, …)
clusterWhether the node runs in a cluster (--node-id)
classesHow many actor classes it serves, as a bucket: 0, 1-9, 10-99, 100-999, 1000-9999, 10000+
resident_actorsHow 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-durable where 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

#FrictionStatus
1No 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.
2Actors 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.
3An 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.
4No 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.
5No 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.
6No 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.
7Testing 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.
8Authorization 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.
9Any 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

PartWhereMade by
Linux binaries (x86_64, aarch64), SBOMs, checksums, Sigstore bundlesthe GitHub release.github/workflows/release.yml, on the tag
The image, linux/amd64 and linux/arm64, signedghcr.io/wasmkeep/wasmkeep:<version>release.yml
The Rust SDKcrates.io, wasmkeep-sdk.github/workflows/publish.yml, when the release is published
The TypeScript SDKnpm, @wasmkeep/sdkpublish.yml
The Python SDKPyPI, wasmkeeppublish.yml
The Go SDKthis 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.

  1. Prepare. In one pull request: the version in Cargo.toml and every manifest, publish = true in crates/wasmkeep-sdk/Cargo.toml (it stays so from then on), the release’s section in CHANGELOG.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 of main.
  2. Tag. Two signed tags on that commit: v<version>, and sdks/go/v<version> for the Go module. release.yml waits 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.
  3. Publish the draft. Read it, then publish it. That starts publish.yml.
  4. Approve the registries. publish.yml first checks the release (the tag is the version of every manifest, CI passed on its commit, the registries environment 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 is publish.yml, started by the release, on the tag. Approving publishes: a version on a registry stays there for good.
  5. Check from outside. In an empty directory: cargo add wasmkeep-sdk, npm install @wasmkeep/sdk, pip install wasmkeep and go 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.yml itself 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 registries environment: required reviewers; deployment limited to the tags v*; administrators not allowed to bypass it; “prevent self-review” once there is more than one maintainer. publish.yml refuses to run if the environment is missing, has no reviewers or accepts any ref.
  • GitHub, the tags: the ruleset that keeps v* and sdks/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.0 that says so in its description: cargo publish of a crate named wasmkeep-sdk, and npm publish of @wasmkeep/sdk. Reserve the unscoped npm name wasmkeep-ts the same way, as a placeholder that points to @wasmkeep/sdk: it is the name of the SDK’s build command, and npx looks a command up as a package when it is not installed. Every real version then goes out through publish.yml, with a provenance statement.
  • crates.io: in the crate’s settings, a trusted publisher: owner WasmKeep, repository wasmkeep, workflow publish.yml, environment registries.
  • npm: in the package’s settings, a trusted publisher with the same values, with npm publish allowed (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, and repository.url in package.json must be this repository.
  • PyPI: a pending publisher for the project wasmkeep with 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 links wasmtime_wasi::p2 and wasmtime_wasi_http::p2 only; the p3 features are off.
  • Rust guests are built with plain cargo for wasm32-wasip2, using the wasip2 crate. cargo-component is not required. The target is listed in rust-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:actor world 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-sdk crate (M1) will use wit-bindgen directly for the wasmkeep:actor world.
  • 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: env added 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, wasmtime bindgen!) and guest bindings (wasmkeep-sdk, wit-bindgen) are generated from this world only.
  • actor: actor-core + wasi:http/proxy@0.2.12 + the wasi:cli imports 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 in wasmkeep 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

  • value is a variant over SQLite’s storage classes: null, integer(s64), real(f64), text(string), blob(list<u8>).
  • query returns rows { 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-statements error otherwise): no accidental batches, no stacked-query injection.
  • Autocommit per statement; explicit BEGIN…COMMIT across 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-error is a record of an enum kind plus a message, so guests can match on the kind (constraint, quota-exceeded, …) without parsing.
  • exec-durable is 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) and wasmkeep_sdk::http::Guest, and call wasmkeep_sdk::export!.
  • Every example is checked in CI against the actor world, so the WIT, the SDK and the examples cannot drift apart.
  • The host must link only what the actor world 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.wit needs 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 websockets interface: send(conn, message), close(conn, code, reason), connections(), with message = text(string) | binary(list<u8>) and send-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: websocket on the actor’s path, answers 101, and asks the guest with on-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, close and connections are scoped to the calling actor.
  • send never blocks the guest: 256 queued messages per connection, then backpressure (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 new Actor hooks 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 against wasmkeep: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/ltx v0.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: LTX1 magic, 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, litetx 0.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 on litetx).
  • Conformance is tested, not assumed: CI generates files with the Go ltx tool 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>.ltx as 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.
  • litetx crate: 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 ltx tool.
  • 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 to superfly/ltx v0.5.3) writes the fixtures in crates/wasmkeep-storage/tests/ltx and 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.34turmoil 0.7.2
ModelReplaces tokio, time and randomness under --cfg madsim; deterministic task schedulingSimulated hosts and network on top of real tokio, one thread, seeded
IntrusivenessThe whole build switches runtime; crates that spawn OS threads or do real I/O (SQLite blocking threads, Wasmtime’s epoch thread) break determinismOnly the code under test must use turmoil’s network and time; the rest of the workspace is untouched
FaultsKill and restart nodes, partitions, latency, cloggingHold and release links, partitions, latency, crash and bounce hosts
MaintenanceActive (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

  1. A toy lease protocol on a Bucket trait (conditional create/update with versions, as S3 and MinIO provide), three nodes, a TTL and renewals.
  2. The same protocol run under both tools with partitions, crashes, delays and a slow bucket, checking the invariant “never two live owners”.
  3. 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.
  4. 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.2madsim 0.2.34
Correct protocol, 200 seedsno double ownership; 1,461 ownership changesno double ownership; 1,552 ownership changes
Planted bug (blind renew)found at seed 1found at seed 1
Failing seed reproducesyes, identical historyyes, identical history
Wall time for 200 seeds127 s1.3 s
Buildplain cargo testRUSTFLAGS="--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, Env and 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 madsim build 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”:

  1. 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).
  2. 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

  1. The head is the newest segment of the highest generation in the listing.
  2. Follow parent links back to the empty database, stopping at the first snapshot on the way.
  3. Apply that snapshot, then the segments after it. Each segment’s pre-apply checksum must equal the running database checksum.
  4. A missing link or a checksum mismatch stops the restore: the actor is marked degraded and refuses events rather than silently rolling back.
  5. (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.
  6. (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.
  7. (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.rs runs 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_class swaps a class at the next event boundary. Running events finish on the old code, databases and sockets are kept, and on-init runs 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

  1. Compile every component locally and link it against the actor world, with the same check serve runs. A broken component is refused before anything is uploaded.
  2. Upload the artifacts and the manifest with conditional create. They are immutable; an existing object with the same key is the same content.
  3. Read current, then replace it with {version, seq: old + 1}, using If-Match on 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:

  1. downloads and compiles all the new classes;
  2. swaps them in, and the seq with 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 serve mode, a manifest format and a test: two nodes, deploy v1, write, deploy v2 with an on-init migration, 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.rs runs 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.

  1. 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 are alarms/<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 in wasmkeep_cluster::alarm_index, pure functions the simulation can share.
  2. 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_indexed meta: <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_fence meta) 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.)
  3. Retries are committed. When on-alarm fails, 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.
  4. 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, run on-alarm if 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 once on-alarm deleted 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 no on-init recorded: deleted since, or never initialised) is removed without running the actor, which would only create it again.
  5. 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 exec event is as durable as that event’s writes.
  • During retries get-alarm returns 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_indexed as “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 tagged alarm_indexed in 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_max on 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 to retry_max late. 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;
      • internal answers 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 internal actor 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, with actors.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 in sim/.
  • 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 under existing, since nobody calls it. existing:NAME,... (added the same day) lists names outside requests may create; the rest of the class stays existing.
  • 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. --access with --app is refused once rules are deployed.
  • If a node cannot read the cluster secret, calls from other nodes look like outside requests: an internal class 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: the https:// 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-listen publish 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): refuse becomes the default when --cluster-listen is set. (Done 2026-11-06 in step 11, before any release rather than in a later one; a node with --node-id and no --cluster-listen refuses 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) and tower-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 in crates/wasmkeep-server/tests/cluster/ and a new tests/cluster_tls.rs.
  • A guest allowed onto the node subnet with --allow-outbound can still reach the public port of other nodes, never the cluster port (it has no pinned certificate).

Open questions, answered for now

  1. Replace the original design’s certificates issued at wasmkeep cluster init? Yes: pins need no CA and no init step.
  2. Is “can read the bucket but not write it” a role worth protecting? Yes, which favours pins over a CA key at rest.
  3. Operator-supplied PKI (--cluster-cert, --cluster-key, --cluster-ca)? Deferred; it would be a second trust root.
  4. 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.

  1. This ADR.
  2. Heartbeat lines for the cluster address and pins (wasmkeep-cluster), no behaviour change.
  3. A connector for the three node clients, plain only, no behaviour change.
  4. Identity and pin verifiers, unit-tested, not wired.
  5. 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.)
  6. Wiring in accept mode, with the whole cross-node suite running over TLS. (Done: ClusterConfig::cluster_tls and serve_with_cluster_listener, library only until step 7. Differences from the text above: a peer’s pins are looked up by the host:port of 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_with refuses a node configured for TLS, so a published cluster address is always served. The cross-node suite runs a second time, from tests/cluster_over_tls.rs, with every node publishing a plain address nobody listens on.)
  7. CLI flags. (Done: wasmkeep serve --cluster-listen ADDR --cluster-advertise https://HOST:PORT, both or neither, only with --node-id and 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.)
  8. Refuse mode. (Done: wasmkeep serve --cluster-plaintext accept|refuse, ClusterConfig::cluster_plaintext (ClusterPlaintext::Accept by default); refuse needs a cluster listener (the CLI and ActorService::new refuse 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 an internal class 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 and actors.call fails, 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.)
  9. 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, makes a stop trusting b, and times each refusal from the event: b’s heartbeat deleted, b unable to write its heartbeat, b’s heartbeat naming a new key while the old process keeps its connections, or a unable to list the heartbeats. In each, a stops sending to b (its pooled connection unused), b’s next request on its kept-alive connection to a’s cluster port gets a 503 and its new handshakes are refused, a WebSocket relayed either way is cut on a’s side, an actors.call in 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.call fails; 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).)
  10. 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 in bench/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 an actors.call between 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’s Connection: close no 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.md now links the cost and the measured revocation times.)
  11. refuse by default. (Done 2026-11-06, before any release, so no running cluster changes behaviour on an upgrade: wasmkeep serve defaults to --cluster-plaintext refuse, and a node started with --node-id and without --cluster-listen refuses 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’s ClusterConfig keeps Accept as 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 as snapshot/<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:

  1. after a periodic snapshot S at position P = (h, t) that this owner uploaded and that was created and acknowledged by this call: the put returned success while the lease was valid (ADR 0007’s rule), not Exists, not Late;
  2. with S’s generation equal to h (the snapshot is at a segment this owner wrote, never at the previous owner’s head);
  3. 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. S and the segment at P were acknowledged, so they were in the bucket before any later generation k could acquire, and k lists after acquiring. Its head is h’s newest segment (or its own, built on it); walking back it reaches P, where S is, and stops. Every object on that walk has generation h and txid t or above (or is k’s own): none is collectable. So a delete of h’s that lands late, after k restored, removes nothing k built on.
  • Late uploads of earlier generations cannot become the head. A segment of generation m < h landing after the collection loses to the segment at P (and anything after it), which has the higher generation. This is why P is kept: with only S left, the prefix would have no segment of generation h, and a late segment of m would become the head, with its parent collected (Missing). A late snapshot of m is 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, generation h: 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 (their last_modified in 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:

  1. This ADR; ROADMAP and docs/cluster.md (“Not yet”) link it. (Done.)
  2. (Done: collectable with 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 at P is 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 with Missing without the guard.) Planned as: collectable(listing, h, S) in wasmkeep-cluster with 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 only S (not the segment at P), and collecting without the generation guard. (Collecting after a late snapshot is not unsafe, see condition 1, so it is not a variant.)
  3. (Done: Collector in replica.rs, triggered from the periodic snapshot and after a delete; --history-retention-secs, default 3600; wasmkeep_history_collected_total and _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_snapshot reports 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 to S, the segment at P and what follows; a restore on another node after a collection; a deleted actor leaves two objects; nothing is collected while the lease is lost.
  4. Deploy artifacts. (Done: AppRef::collect_versions in crates/wasmkeep-server/src/apps/collect.rs, run by wasmkeep deploy after it changed current, with --version-retention-secs (default 3600). The current version and KEEP_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-points current at an existing version): those leak, a few small objects each. Manifest first, candidates oldest first (a pass cut short strands nothing reachable), current re-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.)
  5. benches/restore.rs rerun at 10,000 writes after collection; the benchmarks page updated. (Done: restore/collected/N and list/collected/N, recorded in bench/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)

  1. Retention window: 1 hour, configurable (--history-retention-secs, 0 allowed), except after a delete (see “Deleted actors”).
  2. Deploy artifacts: in this ADR (step 4 of the plan).
  3. 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 and crates/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=existing serves monitor as open, 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=value files the actors read with env.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, inside prepare), with ensure_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 previous in its manifest, was reused when the same table was deployed again later, so previous could 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 open may check names itself; code written for existing may 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 class lines, then the access lines, then the config lines, each group sorted by class;
  • previous <version>\n is 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>
  • access lines: one per class whose mode is not open. <spec> is the rest of the line after the class and one space: existing, internal, or existing: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, by deploy and by the parser.
  • config lines: one per class with configuration. The .config object holds the parsed values, not the file: one NAME='value' line per name, sorted (env::parse reads 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 most env::MAX_FILE bytes.
  • 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: --actor given replaces the class table, as before (classes left out are removed, with their rules and configuration); --actor absent keeps the current classes. --access sets a class’s rule (CLASS=open removes it). --config CLASS=FILE replaces 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 --access it 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 the current read whose ETag the swap is conditioned on, so a concurrent deploy fails with a conflict rather than losing a rule.

  • --access and --config for 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 open says so; if it also adds a class (a rename), it warns that the new class is open unless given a rule.

  • --config is stored in the clear. The flag is not called --env so 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 containing SECRET, TOKEN, PASSWORD, PASS, KEY, CREDENTIAL, AUTH or DSN (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), .config objects and the manifest with create-only writes, then current with If-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):

  1. takes the class once, at its start (admit, or refresh_class for an alarm); nothing later in the event takes another, so prepare runs on it even if a deploy lands while the event waits for the bucket or a lease;
  2. decides access with that class’s rules: an outside request to an internal class is refused (403); to an existing class it reaches only an actor that exists (404 otherwise);
  3. runs on that class, with env.get reading 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 existing the actor must still exist (as before); under a class a deploy made internal, 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-init does 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 --access did at start. ActorEnv, Version and the deployed configuration in ActorConfig print names and counts in Debug, never values.

Node flags

  • --access with --app is accepted only while no version with rules has ever been current (no marker in current), 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 (--actor classes on one node, or wasmkeep dev) --access works as before: there is no deploy to carry it.

  • A node with --app, no --access and 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.

  • --env stays, 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 --env values 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.

  • --env for a class not served is refused at start without --app, as before. With --app it 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:

  1. 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.
  2. 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 --access is now ignored.
  3. Remove --access from 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-init is 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 (previous in current, or a history object per sequence) instead of previous in the id. A history object written before the swap can be orphaned by a crash and then block the next deploy at that sequence; previous in current alone cannot walk back more than one step. Hashing previous keeps 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 --access serves 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 existing can 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 previous chain, and a server-side copy of each component. Rollback goes back one change, whatever kind it was.
  • serve --app with --access is 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; previous is part of the id; .config objects are canonical (comments dropped, quotes and spaces kept, reformatting changes nothing); current with and without the marker.
  • Lineage (tests/cluster/): deploy X, then Y, then X again: the third is a new directory whose previous is Y, and a rollback goes to Y.
  • The class an event takes (tests/deploys.rs and tests/cluster/, mutation-checked):
    • request B is admitted at the front door under open and queues behind A (held at the open fault point); a deploy making the class internal lands; B is refused 403 (mutation: let the front door decide);
    • request B to an existing actor of an existing class waits in its existence check (a bucket whose listing is held); a deploy changing the code and making the class internal lands; B runs on the code it took (mutation: take the class again in prepare).
  • Sockets: an open socket of a class a deploy makes internal is closed with 1008 at its next message.
  • Configuration (tests/deploys.rs): a configuration-only deploy changes what env.get returns, without rerunning on-init, and drops the instance (mutation: keep it); a node --env value wins by name.
  • Cluster (tests/cluster/): two nodes started on a version with rules and no flags: an unknown name of an existing class answers 404 through either node and leaves nothing in the bucket; an actor created by a call is reachable through both; configuration reaches env.get on 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 --app starting on a format-2 version; serve --app --access accepted without the marker (and the flag ignored after a version with rules), refused with it; deploy --access and --config for a class not in the version refused; a deploy without --access keeps the current rules; a configuration-only deploy copies the components; --rollback that reopens refused without --allow-reopen, and prints existing -> 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

  1. This ADR.
  2. Manifest format 2, canonical .config objects, previous in the id, the marker in current, AppRef reading and writing them (S).
  3. 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).
  4. The CLI: deploy --access/--config, keeping what is not named, server-side copies, diffs and warnings, --rollback --allow-reopen, serve --app with or without --access and 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 move built 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 in ensure_owner, which has five callers:

    • HTTP requests and actors.call;
    • the upgrade probe;
    • alarm delivery;
    • ws_open;
    • socket events.

    acquire also 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 with Retry-After: 1.

  • 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::hibernate returns 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 Capacity or Full; 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>, header wasmkeep-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::list lists 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.call reaches 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:

CallerOn Elsewhere(peer)
HTTP request, not yet forwardedforward to the peer; the peer claims
HTTP request already forwarded, or arrived on the cluster port503 with Retry-After: 1 (the sender’s hint lasts at most 1 s)
actors.call from a local guestcall the peer instead
Upgrade probeforward_upgrade to the peer, or 503 with Retry-After: 1 if already forwarded
Alarm deliveryalarm hand-off to the peer (below); the timer retries if it fails
ws_open, socket eventsnone 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:

  1. Close its sockets with 1012 (“actor moving; reconnect”) and wait, up to 5 s, until their on-websocket-close events 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 through ensure_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.

  2. 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. release reports 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.

  3. Drop the actor from memory. Events queued behind the job, and any later ones, find no lease. ensure_owner sends 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.

  4. Hand off its alarm, if it has one. The alarm goes to the chosen peer on a signed node call, the same call actors.call makes. 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 follows actors.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_total shows 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_owner outcome, 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.
  • 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 drained while 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-forwarded itself 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-After or 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.call and 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 move moves a resident actor to the named node;
    • a node alone in its cluster, drained, reports stuck and still serves its actors.
  • Unit tests:
    • heartbeat lines 7 to 9 read by old and new parsers;
    • rendezvous choice and eligibility;
    • move keys expiring by last-modified time;
    • release outcomes.
  • 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 in madsim_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

SliceSizeState
Heartbeat line 9; cluster status shows itSdone
control/ key listing; cluster drain/undrain, actor move CLISdone
Eligibility and rendezvous choiceSdone
ensure_owner returns Elsewhere(peer); the five callers handle itMdone
Awaited release with outcomes; hand-over job in the mailboxMdone
Alarm hand-off on a signed call; paced hand-over; sockets firstMdone
Sim case, adversarial review of the codeMdone
Docs: docs/cluster.md, docs/deploy/three-nodes.mdSdone
Sweep shards skip draining nodes and other appsSdone
actor move (needs the target to acquire first)Mdone 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 in crates/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 by crates/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_at reads 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 put with an expectation: any, absent, or version v. The shard reads the current version and writes only if it matches, otherwise 409 (kv::Error::Conflict). No UPDATE ... 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 to max_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_batch up to 1,000) is one multi-row INSERT run with exec-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 to attempts and hides them until now + 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_attempts leases (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 earliest visible_at. The alarm leases one batch (default 10), sends it in the body of one actors.call to the consumer’s wasmkeep-queue/v1/deliver path, 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 Call error 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 an examples/<lang>-jobs app 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-durable cannot end an explicit transaction on a single node (it changes PRAGMA 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_env merges a version’s configuration with the node’s --env values 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 and on-init does not rerun (ADR 0013).
  • Following. serve --app reads the deployed version before the service starts, then a follower polls apps/<tenant>/<app>/current every 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/ready and metrics: a node that is not ready still holds leases, runs calls and alarms, and takes over actors.
  • env.get (crates/wasmkeep-wit) and env.rs document that secrets come only from the node’s files, and that values change only between events. env::parse reads single-line NAME=value with no escaping.
  • Cryptography in the tree. ring 0.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.get and sends the values out. A bucket reader, or a backup of the bucket, sees actor data and configuration. It also sees cluster/secret in 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):

  1. List secrets/ and read current. The next seq is one more than the highest of the listed objects and current. An object left by a command that died before moving current is skipped over, never reused.
  2. Open the set current names (unless reset, below), apply the change, seal it as the next seq, and create the object with a conditional create.
  3. Move current with If-Match on the version read in step 1, or create it with If-None-Match the first time.
  4. 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 another seq fails.
  • 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.get returns a string), newlines included, so a PEM key fits. Names follow env.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 FILE writes 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 with defaultMode: 0400 (its default, 0644, is refused). On systems without Unix modes, nothing is checked.

  • Two keys during a change. --secrets-key may be given twice, and nodes open an object sealed under either key. To change the key:

    1. give every node the new key as well, restarting them one at a time;
    2. run wasmkeep secrets rotate-key for every app sealed under the old key (--all does every app under the prefix), with --nodes-have-new-key to 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;
    3. 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_FILE writes a whole set, from a file in the plaintext format above, as the next seq, without opening the current one. Nodes need the new key to open it, so it takes --nodes-have-new-key too.

  • 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, and unset takes several names, each as one seq, so nodes never serve half of a related change.
  • Trailing newline. A value from stdin loses exactly one trailing \n or \r\n; one from --file is taken as written.
  • Warnings. set --class warns for a class the current version does not have, as --env files do.
  • Older nodes. set will read the heartbeats and, while a live node does not read secrets from the bucket, refuse without --force: that node would serve env.get as none. Version strings cannot tell (every development build says 0.0.0), so nodes say it on heartbeat line 10 (secrets), except one run with --no-secrets. reset checks 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 before current. No copy of a removed secret stays in the bucket, sealed or not.
  • ADR 0012’s deploy collection must not reach secrets/. Its is_version_id accepts any lowercase id, so a manifest naming secrets as its previous version could send it there. secrets and current are 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 --env values 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 --app opens 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/current every 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 current was written), or unreadable while current answers, means reading current again on the next poll; if it lasts 30 s the node fails closed (below). A corrupt current counts the same.

  • Never backwards, in memory. A node keeps the highest seq it opened since it started and refuses a lower one, or an object whose authenticated seq is not the one current names. A restarted node accepts what current names, so a bucket restored from a backup works once the nodes restart (running ones refuse the lower seq, 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 current names 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.

  • No key, by choice. serve --app with no --secrets-key refuses to start once secrets/current exists, unless --no-secrets says this node runs without them, with --env only. A node started before any secrets existed and keyless fails closed when they appear.

  • Required values: wasmkeep deploy --requires CLASS=NAME,NAME... writes requires <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::parse is strict), so such a version is refused, not misread. A rollback to a version without the lines drops the requirement with them.

  • wasmkeep dev and serve without --app use --env only, 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

  1. Upgrade every node.
  2. Give every node the key (--secrets-key), restarting them one at a time.
  3. wasmkeep secrets set each name.
  4. Remove the names from the --env files and restart the nodes one at a time. Until then --env wins 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.get and 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). ring is 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-server takes a direct dependency on ring (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/secret stays 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/c against a/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 --out creating 0600 and refusing an existing file;
    • is_version_id refusing secrets and current.
  • Cluster (crates/wasmkeep-server/tests/):
    • secrets set reaches every node’s env.get within 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 set keeps 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 --app with no key refuses to start once secrets exist, unless --no-secrets;
    • secrets change while a version the node cannot build is current;
    • current naming a deleted object, and a lower seq after a newer one;
    • --env wins 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 another set that succeeds;
    • two concurrent sets both land;
    • rotate-key, reset;
    • old objects deleted by their successor’s time, never the one before current;
    • set refused while an older node is live.
  • No sim/ case: no lease, failover or ordering is involved beyond a monotonic counter in memory, which the cluster tests cover (a tests/cluster/ case is enough for that).

Plan

SliceSize
Sealed-object format, HKDF, key file and key id; is_version_id tightened; unit testsS
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-secretsM
env.get and env.rs docs, SDK docs, docs/concepts.md, docs/security.md, docs/cluster.mdS
requires in the manifest, fail closed per nameS

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.rs models the node leases of ADR 0020.
  • Amended by ADR 0022 (planned, 0.4.0): move keys move under the app’s root, and actor move gains --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_owner returns Elsewhere { 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::Failed past 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.call goes 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 holds wasmkeep-take 1 and 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:
    1. 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).
    2. 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.
    3. Check that the actor exists (in the bucket, or locally), the way existing access rules do; if not, 404. A mistyped name never creates an actor.
    4. ensure_owner: acquire and restore, no guest code.
    5. 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.
    6. 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:

  1. 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 advertise take. Otherwise the key stays and is tried again on later passes until it expires: a target may be starting, or briefly suspect.
  2. 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.
  3. 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.
  4. 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.
    • Busy (a socket), or the queued events not done within 30 s: clear moving_to, keep the key, try again later.
    • The release failed (Failed): the lease expires on its own (ttl + grace). Clear moving_to, keep the alarm and the key, and try again after that.
  5. The deadline. If 30 s pass with moving_to set 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/take route 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_to in an actor’s state, honoured by ensure_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.
  • 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): extend sim/tests/madsim_drain.rs with 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

SliceSize
Heartbeat line 11 (take); moving_to in ensure_ownerS
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 holdsS
Sim case, adversarial review of the codeM
Docs: docs/cluster.md, ADR 0014’s noteS

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 ReferenceError naming 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 fetch or 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-ts is QuickJS and wasmkeep-ts-starlingmonkey is StarlingMonkey. Before, wasmkeep-ts was StarlingMonkey and wasmkeep-ts-quickjs QuickJS.
  • 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 with http.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-durable for 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-self to 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);
  • status and cancel wait 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) are exec-durable. An answer the runner gives (started, waiting until ...) is never undone by a node failing.
  • on-alarm returns 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, sleep and wait_for_event in a run: the nth call is position n. 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_at later than now), the run stops until then (an event delivered meanwhile does not call f early). Otherwise the runner counts an attempt and records the error so far (a plain write: a trap inside f then counts, and only a node failure in between loses the count), runs f, and records the result with exec-durable. A failure (an error returned; a trap or cut, seen as an attempt without a result) sets next_attempt_at by backoff (1 s doubling to 5 min, 5 attempts by default, StepOptions) and stops. When the attempts run out, step returns the last error to the workflow, which may handle it or fail.
  • sleep(name, duration) and sleep_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, or None after 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 new run_id and running, 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 instance cancelled at 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, sleep and wait_for_event.

Decisions (2026-11-06)

Decisions on the draft’s open questions:

  1. 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.
  2. Retention: finished workflows are kept 7 days by default (configurable), then delete themselves; a later start with the same id is a new run.
  3. 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 in sim/tests/madsim_lease.rs): an owner acts until sent + ttl - safety on its clock, another node takes over only after sent + ttl + grace on 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 or control/ (those move to a separate task at the heartbeat period). A configured heartbeat period above ttl / 3 is refused. Each attempt has a timeout of a third of ttl - safety at most (2 s with the defaults) and is retried within the period.
  • Validity is written_at + ttl - safety on the node’s own clock, where written_at is 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 otherwise If-Match on 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’s fenced or released write naming the instance the check first read is not a twin: it proves that process lapsed once the object’s kept written_at + ttl + grace has 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-Match on 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 is live and 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 stored written_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, and wasmkeep serve exits at once with a non-zero status: no connection drain, no releases, no released write (the fence, or the other instance, already frees the records). A supervisor restarts it (systemd Restart=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-Match on the last acknowledged version. Success proves nodes/<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 live write 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, with nodes/A read 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 with If-Match r 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_valid becomes “this hold exists and the node lease is valid”; the acknowledgement rule of Replica::put, alarm delivery, the alarm index rules, garbage collection, exec-durable and 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-Match on 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-Match v, and never adopts a version read later (the re-read-and-adopt rule of section 1 is for nodes/<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-Match on 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 (a fenced or released object, or a node missing from the view, is not live). Routing never reads nodes/<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_at is 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 and cluster status shows it. A stale one is a retired node’s: cluster status shows 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 + grace not 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 a wasmkeep-lease 2 marker with If-None-Match: *; if it is present and not live, replace it with the marker, If-Match on its version. Old code reads the marker as corrupt and never takes it over (LeaseRecord::decode accepts only wasmkeep-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 create owners/ 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 status shows 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 rewrites leases/ from owners/, 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:

BoundValueWhy
Fences at once (FENCE_CONCURRENCY)8fences 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)32after 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 stops32a 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 parkedhibernation 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 gracethe 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 status shows each node lease’s state; the metrics gain successful lease writes and fences.
  • The upgrade is one-way (section 6).

Verification

  1. 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.
  2. Simulation (sim/): a node-lease model in the style of madsim_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.
  3. 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 writing released; 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 fence makes 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 a sim/ case); the upgrade seeds generations above every existing segment, marks leases/ for found and new actors, and refuses a live old record that names the starting node’s own id.
  4. 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.
  5. An adversarial review of this record, then of the code.

Recorded runs:

  1. 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.
  2. 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 first check.sh --deep run (20,000 seeds each): madsim_node_lease 317,342 acquisitions with a twin process, every broken model caught; every other simulation passed after one checker fix in madsim_drain (its deadline ignored a third node’s bucket outage).
  3. These cases run in tests/cluster/ and cluster/tests.rs on every check.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.
  4. 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 build 82dab61, not of the bucket’s speed; where in the durable write it lies is open.
  5. 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 is node_lease::FenceWatch, and the routing read of a record Owners::other_owner. Ownership records stay in node_lease::Owners rather than behind the Leases API: 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 / 3 is 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 writes released last.
  • Fencing. Peers fence stale nodes (FenceWatch over 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 status shows lease states. cluster_tls drops 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 writes released; 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_total replaces 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 by cluster 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) and cluster 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 deploy of 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; deploy checked 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 example acme/shop. The unit of deploy, version, rollback and secrets, and, from 0.4.0, of actor state. Written tenant/app wherever 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. user in acme/shop and user in acme/admin are 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 stays tenant/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:http component. It receives every request outside /actor/ and /_wasmkeep/, holds no state, calls the app’s actors with the same actors.call actors 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 no app 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 status and 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/:

FamilyBefore 0.4.0From 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 seedleases/<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, secretsapps/<t>/<a>/...unchanged
shared by the prefixnodes/, 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 beside apps/, not inside it, so the deploy garbage collector, which treats every child of apps/<t>/<a>/ as a version, can never reach it. An app is two prefixes, apps/<t>/<a>/ and state/<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:

  1. 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.
  2. Lock: create cluster/migration holding the plan, each app’s current current token and the full nodes/ set.
  3. Copy server-side into R. History objects, which are create-only, are skipped when present; mutable objects (records, markers, alarms) are copied every time.
  4. 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.
  5. Barriers: mark every app’s current with 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.
  6. Final proof: list nodes/ again; any new or changed key means a node ran, and the run aborts with nothing committed.
  7. 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, deploy and dev on a bucket that has cluster/migration or any cluster/migrated/<tenant>. Both names are reserved from 0.3.0.
  • 0.2 serve --app and deploy cannot read token 3, and stop.
  • 0.2 serve --actor gets 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 to state/<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; --rollback before the first start and the bare data kept until --remove-old cover the realistic mistakes.
  • Fencing live nodes from migrate. A fenced node under a service manager restarts, and a wrong --bucket would fence production. It refuses live nodes instead.