A small Zig package for building lazily activated, single-threaded, stateful services with private durable storage, with native TypeScript bindings for Node.js and Bun. The packaged TypeScript native libraries include SQLite and require no external runtime library.
A Service is a user-defined state machine. The Runtime activates it on demand, replays its durable log, and then feeds it messages one at a time:
decide()inspects the current state and message, returning aDecision(an optional mutation + optional reply).- If the decision contains a mutation,
appendOnce()records it once for that actor and message ID, together with the optional reply. - Only after a new append succeeds does the runtime call
apply()to update in-memory state. - Snapshots are taken periodically and on passivation, compacting the log.
This serializes actor state changes without locks and suppresses duplicate mutation appends for retained message IDs.
Aktorz provides per-actor mutating-decision deduplication with stored-reply return. decide() still runs on every request attempt; a repeated message ID returns the first stored reply only when the current attempt also produces a mutation, and decisions without mutations are neither looked up nor recorded (so they may observe newer state). This is not exactly-once request execution. See docs/deduplication.md for message-ID scope, lifecycle, retention, and conflicting-payload behavior.
Requires Zig 0.17.0. A Nix flake is included:
nix develop # enter the dev shell
zig build test # run core + example + TypeScript ABI tests
zig build sqlite-test # run SQLite-backed tests using the bundled amalgamationTry the cart gateway:
zig build cart-gateway # starts on port 7070
printf 'kind: cart\nkey: acme:c-42\nmessage-id: 1\ncontent-length: 20\n\nadd|red-socks|2|1299' | nc localhost 7070npm install aktorzimport { Runtime, utf8 } from "aktorz"
const runtime = Runtime.sqlite({
path: "actors.sqlite3",
snapshotEvery: 64,
})
runtime.register("counter", {
create: () => ({ value: 0 }),
loadSnapshot(state, snapshot) {
state.value = Number(utf8(snapshot))
},
makeSnapshot: (state) => String(state.value),
decide(state, message) {
const delta = Number(utf8(message))
return { mutation: String(delta), reply: String(state.value + delta) }
},
apply(state, mutation) {
state.value += Number(utf8(mutation))
},
})
const reply = runtime.request({ kind: "counter", key: "counter-42" }, 1n, "5")
console.log(reply && utf8(reply))
runtime.close()Use Runtime.memory() for tests and ephemeral state. Runtime.sqlite() uses the same actor API while persisting snapshots, mutation-log entries, and the per-actor deduplication records and optional replies created by mutating decisions across close/reopen cycles. The package loads a self-contained Zig shared library through Koffi on Node.js or bun:ffi on Bun. Build the host artifact from a checkout with npm run build; cross-build the complete package matrix with npm run build:native:all. Runnable TypeScript examples (counter, cart, bank, SQLite durability) live in examples/typescript/. See docs/typescript.md for the callback contract, SQLite options, ABI ownership, and packaging details.
In your build.zig.zon, add the dependency:
.dependencies = .{
.aktorz = .{ .path = "../aktorz" },
},Then in build.zig, import the module:
const durable_dep = b.dependency("aktorz", .{ .target = target, .optimize = optimize });
exe.root_module.addImport("durable_actor", durable_dep.module("durable_actor"));For SQLite persistence, also add:
exe.root_module.addImport("durable_actor_sqlite", durable_dep.module("durable_actor_sqlite"));The module bundles the SQLite amalgamation and links it for you; no system sqlite3 is required.
Implement five methods and the runtime handles the rest:
const durable = @import("durable_actor");
pub const MyService = struct {
// ... your state fields ...
pub fn create(alloc: Allocator, address: durable.Address) !*MyService { ... }
pub fn destroy(self: *MyService, alloc: Allocator) void { ... }
pub fn loadSnapshot(self: *MyService, bytes: []const u8) !void { ... }
pub fn makeSnapshot(self: *MyService, alloc: Allocator) !durable.OwnedBytes { ... }
pub fn decide(self: *MyService, alloc: Allocator, message: []const u8) !durable.Decision { ... }
pub fn apply(self: *MyService, mutation: []const u8) !void { ... }
};Register it with the runtime:
var store = durable.MemoryNodeStore.init(alloc);
var runtime = durable.Runtime.init(alloc, store.asStoreProvider(), .{ .snapshot_every = 64 });
defer runtime.deinit();
defer runtime.shutdown() catch unreachable;
try runtime.registerFactory("my_kind", durable.Factory.from(MyService, MyService.create));
const reply = (try runtime.request(
.{ .kind = "my_kind", .key = "tenant:entity-42" }, 1, "some-command|arg",
)).?;
defer reply.deinit();See examples/cart_example.zig and examples/bank_example.zig for complete working services, and examples/typescript/ for their TypeScript counterparts.
| Type | Role |
|---|---|
Runtime |
Manages activations, mailboxes, replay, snapshotting, passivation |
Service |
Vtable interface for user state machines |
Factory |
Creates Service instances from an Address |
StoreProvider / ScopedStore |
Pluggable durability boundary (log + snapshots) |
Resolver / Forwarder |
Optional hooks for routing across nodes |
MemoryNodeStore |
In-memory store for tests and demos |
SQLiteNodeStore |
SQLite-backed store (separate durable_actor_sqlite module) |
TinyGateway / TcpGateway |
Minimal framed TCP gateway |
One connection carries one request.
Request:
kind: <service-kind>
key: <service-key>
message-id: <decimal-u128>
content-length: <decimal-usize>
<payload-bytes>
Response:
status: ok|noreply|error
content-length: <decimal-usize>
<reply-or-error-body>
| Command | Description |
|---|---|
zig build cart-gateway |
In-memory cart service on port 7070 |
zig build cart-sqlite-gateway -- actors.sqlite3 |
SQLite-backed cart service |
zig build bank-gateway |
Bank account with freeze/close lifecycle |
zig build -Doptimize=fast bench |
Benchmark runner (default: SQLite suite) |
Bank account commands: deposit|<cents>|<memo>, withdraw|<cents>|<memo>, set_overdraft|<cents>, freeze|<reason>, unfreeze, close, balance, statement.
zig build test # core + example + TypeScript ABI tests
zig build sqlite-test # SQLite + benchmark tests
zig build typescript-native # host TypeScript library into native/<key>/
zig build typescript-native-all # packaged TypeScript library matrix
npm run build # TypeScript + current-host native artifact
npm run test:node # Node.js memory + SQLite integration tests
npm run test:bun # Bun memory + SQLite integration tests
zig build -Doptimize=fast bench # default SQLite suite
zig build -Doptimize=fast bench -- --mode micro --scenario memory_hot --ops 1000000Benchmark runner:
zig build -Doptimize=fast benchThat default run now executes the SQLite scale suite in --mode sqlite-suite. If you omit --sqlite-path, the benchmark creates fresh database files under .zig-cache/bench/.
Recommended invocations:
zig build -Doptimize=fast bench -- --mode micro --scenario memory_hot --ops 1000000
zig build -Doptimize=fast bench -- --mode sqlite-suite --sqlite-path actors-bench.sqlite3
zig build -Doptimize=fast bench -- --mode sqlite-reactivate --actors 1000 --history-preload 256 --sqlite-path actors-bench.sqlite3Modes:
micro: preserves the original single-actor benchmark formemory_hot,memory_cold,sqlite_hot, andsqlite_coldscenarios.sqlite-suite: runs freshchurn,reactivate, andsoakphases with the default420second3:2:2split.sqlite-churn: mixed skewed actor traffic with periodic passivation.sqlite-reactivate: repeated preload, passivate, and first-getcold activation rounds.sqlite-soak: mixed workload plus 10-second stability samples.
Key flags:
--actors,--duration-seconds,--write-percent,--passivate-every,--snapshot-every,--history-preload, and--sqlite-path.--mode microalso takes--scenarioand--ops.- Legacy positional micro invocations still work during the transition, for example
zig build -Doptimize=fast bench -- memory_cold 250000. - If you supply
--sqlite-path, the benchmark refuses to reuse an existing database, WAL, or SHM file. Insqlite-suite, the supplied path becomes the base name for phase-local files such asactors-bench.churn.sqlite3.
- Call
shutdown()beforedeinit()to snapshot and passivate active services cleanly.deinit()only releases memory. - TypeScript actor callbacks are synchronous by design; promises are not supported inside a native request.
Runtime.close()performs the native shutdown before releasing the store and FFI resources.
docs/deduplication.md— exact message-ID deduplication boundary, stored replies, and retentiondocs/typescript.md— TypeScript API, SQLite runtime, native ABI, and packagingexamples/typescript/— runnable TypeScript examples (counter, cart, bank, SQLite durability)docs/sqlite_store.md— SQLite store designdocs/sqlite_schema.sql— SQLite schema