ZenZip logozenzip

Configuration

Every option on zenzip(), the duration format, structured logging, and the app lifecycle.

zenzip() options#

config.ts
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 });
  },
});
OptionTypeDefaultDescription
dataDirstring".zenzip"Directory for the embedded store (zenzip.db, SQLite WAL). Created on start.
store{ driver: "sqlite" } | { driver: "postgres", url }sqliteStorage 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.
handleSignalsbooleantrueInstall SIGINT/SIGTERM handlers that gracefully stop the app, then exit.
sweepDuration"5s"How often lease-expired (crashed) jobs are returned to pending. Lower = faster crash recovery, slightly more idle I/O.
schedulerTickDuration"250ms"Scheduler + workflow-timeout sweep cadence. Bounds schedule fire precision.
workerThreadsnumber2Tokio 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) => voidReceive structured engine logs ({ level, target, message }). Use pinoLogger()/winstonLogger() adapters. Without it, logs go to stderr.
onError(err, ctx) => voidError sink: engine errors the runtime would otherwise only log (native log-callback + background-loop failures). Wire to Sentry via sentryReporter(Sentry).
onAudit(entry: AuditEntry) => voidAudit 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).
encryptionKeystringPayload 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#

logging.ts
// 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 tracing into 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 use FOR UPDATE SKIP LOCKED; cross-node wakeups ride LISTEN/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 dedicated zenzip schema. 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 versioning rules). 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.

retention.ts
// 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; a Duration sets the window.
  • The sweep is index-backed (runs(status, updated_at)), and rows removed are exposed as runsGc / stepsGc / eventsGc in app.metrics(). app.gc() triggers a pass immediately.

Health & operations#

ops.ts
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. Returns 503 until 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 doctor reports 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 alerts and a background loop watches for dead-letter-queue growth and stuck runs, calling onAlert — wire it to PagerDuty/Slack/email.
ops.ts
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 clear zenzip config: … error on misconfig (e.g. a postgres store with no url, a negative payload threshold) — fail fast, not a cryptic runtime crash. Call validateConfig(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 onAudit to 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#

lifecycle.ts
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 resolves true when all in-flight handlers drained within the timeout.
  • After stop(), the SQLite handle is closed — data dirs can be deleted immediately (matters on Windows).