Configuration
Every option on zenzip(), the duration format, structured logging, and the app lifecycle.
zenzip() options#
import { zenzip } from "zenzipjs";
const app = zenzip({
dataDir: ".zenzip", // where the SQLite store lives
handleSignals: true, // SIGINT/SIGTERM → graceful stop
sweep: "5s", // lease-expiry sweep cadence
schedulerTick: "250ms", // scheduler loop cadence
workerThreads: 2, // engine tokio worker threads
encryptionKey: process.env.ZENZIP_KEY, // AES-256-GCM payloads at rest (opt-in)
logLevel: "info",
logger: ({ level, target, message }) => {
myLogger.log({ level, target, message });
},
});| Option | Type | Default | Description |
|---|---|---|---|
| dataDir | string | ".zenzip" | Directory for the embedded store (zenzip.db, SQLite WAL). Created on start. |
| store | { driver: "sqlite" } | { driver: "postgres", url } | sqlite | Storage backend. sqlite is embedded + zero-config (single node); postgres enables multi-node (SKIP LOCKED claims, LISTEN/NOTIFY wakeups, advisory-lock scheduler election) with the same API. |
| handleSignals | boolean | true | Install SIGINT/SIGTERM handlers that gracefully stop the app, then exit. |
| sweep | Duration | "5s" | How often lease-expired (crashed) jobs are returned to pending. Lower = faster crash recovery, slightly more idle I/O. |
| schedulerTick | Duration | "250ms" | Scheduler + workflow-timeout sweep cadence. Bounds schedule fire precision. |
| workerThreads | number | 2 | Tokio worker threads for the engine runtime. 2 is plenty — handlers run on your event loop, not these threads. |
| logLevel | "error" … "trace" | "off" | "off" | Engine log verbosity. Defaults to "info" when a logger is provided. |
| logger | (event: LogEvent) => void | — | Receive structured engine logs ({ level, target, message }). Use pinoLogger()/winstonLogger() adapters. Without it, logs go to stderr. |
| onError | (err, ctx) => void | — | Error sink: engine errors the runtime would otherwise only log (native log-callback + background-loop failures). Wire to Sentry via sentryReporter(Sentry). |
| onAudit | (entry: AuditEntry) => void | — | Audit sink: privileged actions — workflow trigger/cancel, DLQ requeue, agent approve/deny, runs.cancel, subject.purge. |
| alerts | { onAlert; interval?; dlqThreshold?; idle? } | — | Operational alerting: background watch for DLQ growth + stuck runs → onAlert. Wire to PagerDuty/Slack. |
| retention | { runs?, events?: Duration | "off"; sweep?: Duration } | runs/events "7d", sweep "1h" | Retention GC: a background sweep deletes aged terminal runs (+ steps) and old events so tables don't grow forever. Set a window to "off" to keep that category indefinitely. |
| payloads | { threshold?: number; store?: BlobStore } | — | Large-payload offloading: step results over `threshold` bytes (default 64 KiB) go to a blob store; the journal keeps a reference. Default store is the filesystem; multi-node needs a shared store (e.g. S3). |
| encryptionKey | string | — | Payload encryption at rest: AES-256-GCM on job payloads, run inputs/outputs, step results, and event payloads. Load from an env var or secret manager — never hard-code, never lose it. Transparent to enable on an existing DB (legacy plaintext stays readable). |
Duration strings#
Every duration option accepts a number (milliseconds) or a string: "250ms", "30s", "5m", "2h", "1d". Fractions work ("1.5h"). Invalid strings throw at definition time, not at runtime.
Logging#
// Route engine logs into your logging stack:
const app = zenzip({
logLevel: "info",
logger: (e) => pino.info({ target: e.target }, e.message),
});
// Or just stderr — set logLevel without a logger:
const app2 = zenzip({ logLevel: "debug" });
// Example events you'll see:
// INFO zenzip_core::runtime zenzip runtime started queues=3 schedules=1
// INFO zenzip_core::runtime recovered lease-expired jobs count=2
// DEBUG zenzip_core::workflow step retry scheduled run=… step=charge- The bridge from Rust
tracinginto JS uses a weak threadsafe function — holding a logger never keeps the Node event loop alive, so your process still exits cleanly. - The subscriber is process-global: the first app that configures logging wins for the process lifetime. Relevant only if you run multiple
zenzip()apps in one process (tests, mostly). - Targets are Rust module paths (
zenzip_core::queue,zenzip_core::workflow…) — filter on them in your log pipeline.
Storage#
- SQLite (default): WAL mode,
synchronous=NORMAL,busy_timeout=5s, migrations embedded. Everything — jobs, schedules, runs, step journal — lives in one file you can inspect with any SQLite client. - Backups:it's SQLite — snapshot the file (or use
.backup) while the app runs; WAL keeps readers consistent. - Postgres (multi-node):
store: { driver: "postgres", url }— same API, horizontal scale. Claims useFOR UPDATE SKIP LOCKED; cross-node wakeups rideLISTEN/NOTIFY(a push on node A wakes node B's dispatchers instantly); schedule ticks are CAS-elected so N nodes fire each tick exactly once; dead nodes are recovered by the same lease sweep as dead processes. Tables live in a dedicatedzenzipschema. Keep the database network-close — pushes/triggers cost a round-trip. - Rolling deploys: in-flight runs pin their workflow version; old and new nodes can coexist as long as step ids stay stable (see
workflow versioningrules). SQLite→Postgres data migration tooling is on the backlog — today, drain one store and point at the other.
Multiple processes, one store
WAL supports concurrent processes on one data dir (producer web server + consumer worker is the common split). Heavy multi-process write contention is still being characterized — the 4-process benchmark is an open task before this becomes a documented first-class pattern.
Retention & GC#
A durable engine persists every run, step, and event — so without retention the store grows without bound (and the Postgres queue tables degrade past ~100k rows). A background GC sweep deletes aged terminal runs (completed / failed / cancelled), their step journal, and old events. It is on by default.
// Defaults: keep 7 days of runs + events, sweep hourly.
const app = zenzip({
retention: {
runs: "30d", // keep terminal runs (+ their steps) 30 days
events: "7d", // keep events 7 days
sweep: "1h", // GC sweep cadence
},
});
// Keep everything forever (e.g. you archive elsewhere):
zenzip({ retention: { runs: "off", events: "off" } });
// Run a GC pass on demand (ops / cron): returns rows removed.
const { runs, steps, events } = app.gc();- Only terminal runs are eligible — an in-flight, sleeping, or waiting run is never collected, however old.
- Age is measured from the last update (completion time for runs, emit time for events), not creation — a long run that just finished is safe.
- Each category is independent:
"off"keeps it forever; aDurationsets the window. - The sweep is index-backed (
runs(status, updated_at)), and rows removed are exposed asrunsGc/stepsGc/eventsGcinapp.metrics().app.gc()triggers a pass immediately.
Health & operations#
await app.start();
await app.listen({ port: 3000 });
// Probes for orchestrators (Kubernetes, ECS, …):
// GET /healthz → 200 { status: "alive" } liveness, zero store I/O
// GET /readyz → 200 { status: "ready" } readiness: store reachable
// 503 { status: "unavailable" } not ready — hold traffic
app.health(); // { alive, ready } in-process
// Find runs that stopped progressing (lost wakeup / stalled sweep):
const stuck = await app.orphanedRuns({ idle: "10m" });
// [{ runId, workflow, status, idleMs, reason }]- Liveness (
/healthz) = the process is up and the engine responds — no store I/O, so a slow database never fails it (which would trigger needless restarts). - Readiness (
/readyz) = started and the store answers a ping — gate rolling deploys and load balancers on it. Returns503until ready. A route you define yourself at the same path takes precedence. app.orphanedRuns()surfaces non-terminal runs idle past a window (default 5m) — a sleeping run past its wake, a wait past timeout, a lost execution wakeup. They should be rare;zenzip doctorreports them too.- Bulk cancel:
app.cancelRuns({ workflow, status, olderThan })cancels every matching non-terminal run (and its children) — incident response or draining before a breaking deploy. - Alerting: set
alertsand a background loop watches for dead-letter-queue growth and stuck runs, callingonAlert— wire it to PagerDuty/Slack/email.
const app = zenzip({
alerts: {
onAlert: (a) => pager.notify(a.message), // { type, message, count, queue?, runs? }
interval: "1m",
dlqThreshold: 10, // alert once a queue's DLQ reaches 10
idle: "5m", // "stuck run" threshold
},
});
// Bulk-cancel everything still running on a broken workflow:
const { cancelled } = await app.cancelRuns({ workflow: "import", status: "running" });Config hardening#
- Boot-time validation:
app.start()validates options first and throws a clearzenzip config: …error on misconfig (e.g. a postgres store with nourl, a negative payload threshold) — fail fast, not a cryptic runtime crash. CallvalidateConfig(opts)yourself to pre-check. - Secrets: load tokens /
encryptionKey/ the postgres URL from the environment, never source.redactUrl(url)masks the password for safe logging. - Audit log: pass
onAuditto record privileged actions (workflow trigger / cancel, dead-letter requeue, agent approve / deny) — each entry is{ action, target, at, detail }; wire it to an append-only store for a queryable trail. A throwing sink never breaks the action.
Lifecycle#
const app = zenzip();
// 1. Definition phase — queues, schedules, workflows. No I/O yet.
const q = app.queue("work");
q.process(async (job) => { /* … */ });
// 2. Start: opens the store (creating dataDir), runs migrations,
// registers everything with the Rust engine, spawns dispatchers.
await app.start();
// 3. Runtime phase — push, trigger, emit from anywhere.
await q.push({ n: 1 });
// 4. Graceful stop: stop claiming → drain in-flight (up to timeout)
// → release handlers → close the store. Returns true on clean drain.
const clean = await app.stop({ timeout: "30s" });- Definitions after
start()throw — registration is a startup-time act by design (the engine wires queues, schedules, and workflow executors once). stop()is idempotent and resolvestruewhen all in-flight handlers drained within the timeout.- After
stop(), the SQLite handle is closed — data dirs can be deleted immediately (matters on Windows).