Skip to content
sammyjoycePublic

About

Lazily activated, single-threaded, stateful services with private durable storage for Zig

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

42 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

aktorz

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.

How it works

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:

  1. decide() inspects the current state and message, returning a Decision (an optional mutation + optional reply).
  2. If the decision contains a mutation, appendOnce() records it once for that actor and message ID, together with the optional reply.
  3. Only after a new append succeeds does the runtime call apply() to update in-memory state.
  4. 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.

Deduplication semantics

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.

Quick start

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 amalgamation

Try 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 7070

Use from TypeScript

npm install aktorz
import { 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.

Add to your Zig project

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.

Define a service

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.

Key types

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

TCP gateway protocol

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>

Examples

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.

Build steps

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 1000000

Benchmark runner:

zig build -Doptimize=fast bench

That 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.sqlite3

Modes:

  • micro: preserves the original single-actor benchmark for memory_hot, memory_cold, sqlite_hot, and sqlite_cold scenarios.
  • sqlite-suite: runs fresh churn, reactivate, and soak phases with the default 420 second 3:2:2 split.
  • sqlite-churn: mixed skewed actor traffic with periodic passivation.
  • sqlite-reactivate: repeated preload, passivate, and first-get cold 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 micro also takes --scenario and --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. In sqlite-suite, the supplied path becomes the base name for phase-local files such as actors-bench.churn.sqlite3.

Notes

  • Call shutdown() before deinit() 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.

Further reading

About

Lazily activated, single-threaded, stateful services with private durable storage for Zig

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages