Architecture
A Rust engine embedded in your Node process: how the boundary works, why there's exactly one engine, and where state lives.
Overview#
ZenZip is two layers joined by napi-rs. TypeScript owns the developer experience: typed APIs, validation, your handler code. Rust owns everything that must not depend on the event loop being alive and responsive: timers, leases, retries, cron evaluation, persistence, crash recovery.
┌─────────────────────────────────────────────────────┐
│ TypeScript API (npm: zenzipjs) │
│ app.queue() · app.schedule() · app.workflow() │
│ app.emit() · typed payloads · Standard Schema │
└──────────────────────┬──────────────────────────────┘
│ napi-rs v3
│ sync calls (registration, push,
│ trigger, emit, recordStep)
│ ThreadsafeFunction dispatch
│ (Rust → JS handler invocation)
┌──────────────────────▼──────────────────────────────┐
│ Rust Runtime Core (zenzip-core · own tokio rt) │
│ │
│ Queue Engine Scheduler Workflow │
│ leases, backoff, cron + every, Engine │
│ DLQ, rate limits, tz, catchup, step journal, │
│ batch dispatch jitter suspensions │
│ │
│ Sweepers: lease expiry · event timeouts │
└──────────────────────┬──────────────────────────────┘
│ Store trait
┌──────────────────────▼──────────────────────────────┐
│ SQLite (WAL) — default, zero config │
│ PostgreSQL — multi-node via SKIP LOCKED │
└──────────────────────────────────────────────────────┘The NAPI boundary#
Every JS↔Rust crossing has a cost, and we measured it before designing the protocol (benchmarks). The rules that fell out of those numbers are hard rules:
| Crossing | Measured cost | Rule |
|---|---|---|
sync fn | 14–34 ns | Use freely: push, trigger, emit, recordStep are all sync |
async fn | ~85–100 µs (!) | Banned on hot paths. Only cold calls like stop() and waitForRun() |
TSFN round-trip | 36 µs solo → 2.4 µs pipelined ×256 | All handler dispatch; the engine keeps many in flight |
Concretely, the queue dispatch loop:
// What actually happens when a job is processed:
//
// 1. [Rust] dispatcher claims a batch:
// UPDATE jobs SET status=1, attempt=attempt+1, lease_until=...
// WHERE id IN (SELECT ... ORDER BY priority DESC LIMIT n)
// 2. [Rust] spawns a task per job group; renews leases at lease/3 cadence
// 3. [Rust→JS] ThreadsafeFunction call with the job payload (pipelined —
// hundreds can be in flight)
// 4. [JS] your handler runs; resolve = ack, reject = nack
// 5. [Rust] ack deletes the row; nack schedules retry with backoff,
// or dead-letters when attempts are exhaustedThe workflow engine adds one more boundary optimization: the entire journal of completed steps is batch-prefetched into JS at the start of each execution attempt, so step.run() memoization hits never cross the boundary at all. Only recording a newly completed step does (one sync call, ~tens of µs of SQLite).
One engine, four surfaces#
There is a single execution engine — the queue engine — and everything else is expressed through it:
- Queues are the engine directly: leased claims, per-group concurrency permits, token-bucket rate limits, exponential backoff, DLQ.
- Schedules persist their next fire time; the scheduler loop enqueues a job onto a hidden queue (
zenzip.schedule.<name>) whose concurrency encodes the overlap policy. - Workflow runs are jobs on
zenzip.workflow.<name>. The handler loads the run, prefetches the journal, invokes the JS executor, and applies the outcome. Astep.sleep()is just a delayed job; a crashed attempt is recovered by the same lease sweep that recovers queue jobs. - Agents are workflows whose steps are generated dynamically — every LLM call and tool execution a journaled step, inheriting retry, resume, and human-in-the-loop for free.
Why this matters
Crash recovery, retries, backoff, and observability are implemented once and tested once. The SIGKILL chaos harness that validates queue recovery is the same machinery that makes workflows durable.
Storage#
The store is a Rust trait with pluggable backends:
- SQLite (default). WAL mode,
synchronous=NORMAL, embedded migrations viauser_version. Measured at 209k inserts/s and ~10k worst-case claim+ack/s on a laptop — far beyond most apps' needs. Zero configuration: the file lives indataDir. - PostgreSQL (multi-node). Same trait, claims via
FOR UPDATE SKIP LOCKED, for multi-node deployments. No custom consensus — Postgres already solved distributed state.
Atomicity rules worth knowing: job claims increment the attempt counter and set the lease in one statement; run-state transitions are guarded (UPDATE … WHERE status = expected) so a wake can never be applied twice; completed journal entries are never overwritten.
Process & threading model#
The engine runs on its own tokio runtime(2 worker threads by default), fully independent of Node's event loop and of napi's internal runtime. Idle waits — poll timers, lease renewal, cron ticks, sweepers — never wake your event loop. The only JS execution ZenZip causes is your own handler code.
- Graceful shutdown:
app.stop()cancels claiming, drains in-flight handlers up to a timeout, releases the ThreadsafeFunctions (so the event loop can exit), shuts the runtime down in the background, and closes the SQLite handle. - Multiple processes, one box: WAL supports it; producers and consumers can be separate processes sharing the data dir — a common pattern for producer web servers with dedicated worker processes.
- Multiple machines: via Postgres — same API, horizontal scale.
Decision log#
The plan records eight numbered decisions (D1–D8) with the reasoning and, where applicable, the measurements that settled them:
| ID | Decision | Status |
|---|---|---|
| D1 | Step memoization, not Temporal-style replay | shipped |
| D2 | SQLite embedded default; Postgres for scale-out | shipped |
| D3 | Coarse boundary: sync calls + pipelined TSFN | shipped, benchmark-verified |
| D4 | Agents are workflows with dynamic steps | shipped |
| D5 | HTTP stays a Node adapter (Rust server: NO-GO by benchmark) | settled |
| D6 | Content-hash workflow versioning, stable step ids | shipped |
| D7 | At-least-once delivery; effectively-once step recording | shipped |
| D8 | Own tokio runtime; producer/consumer multi-process model | shipped |
Benchmark methodology and raw numbers behind each decision: Benchmarks.