Background work and workflows
Anything that doesn't need to happen inside the request goes through the outbox and runs as its own bounded job. Job limits are larger than route limits: 500 reads, 200 writes, 5,000 ops.
Events
const OrderPaid = a.event("orderPaid", { payload: { orderId: t.id() } });
OrderPaid.emit({ orderId }); // in a route or handler, after commit
a.onEvent("receipt", OrderPaid, ($, { payload }) => { /* ... */ });Every handler of an event runs once per emit.
Entity triggers
a.onInsert("welcome", User, ($, { payload }) => { /* payload.after is the new row */ });
a.onUpdate("audit", Task, ($, { payload }) => { /* payload.before, payload.after */ });
a.onDelete("cleanup", Project, ($, { payload }) => { /* payload.before */ });Trigger cycles
Each handler run is bounded by the job limits, but a handler can set off another one: by writing a row, emitting an event, enqueueing a job, or starting a workflow. If that leads back to the first handler, the chain would never end. The checker builds the graph of which handler can set off which, and rejects every cycle:
✖ trigger_cycle at app.ts:31:3 (handlers[0])
handlers "onPing" → "onPong" → "onPing" form a trigger cycle: each run can set off the next,
without end. Break it (write a different entity, emit a different event, or use a different
queue), or, if a guard stops the chain on its own, declare maxDepth on every handler in it.When a cycle is intended and a guard ends it, declare maxDepth on every handler in it:
// Closing a task also marks it done. That update triggers this handler once more, and the
// guard stops it there.
a.onUpdate("closeMeansDone", Task, ($, { payload }) => {
const task = payload.after;
$.when(task.status.eq("closed").and(task.done.not()), () => {
task.update({ done: true });
});
}, { maxDepth: 2 });The runtime counts hops: a handler a route sets off is hop 1, the handler that one sets off is hop 2, and so on. A run past its handler's maxDepth, or past 16 for any handler, is refused and reported as a failure, which ends the chain. A cycle that starts a new workflow instance on every turn can't be allowed with maxDepth.
One loop is allowed without maxDepth: a queue job that re-enqueues its own queue to work through data in batches (cascade deletes do this). That's a continuation, not a new hop, and it may continue up to 10,000 times in a row.
Queues
const Charge = a.queue("charge", { payload: { orderId: t.id() }, retries: 3, backoff: "2s", maxBackoff: "1m" });
Charge.enqueue({ orderId }); // now
Charge.enqueue({ orderId }, { delay: "1h" }); // later (at most 30 days)
Charge.enqueue({ orderId }, { delay: "1h", key: orderId }); // keyed: replaces a pending job with this key
Charge.cancel(orderId); // cancels the pending keyed job
a.onQueue("chargeOrder", Charge, ($, { payload, errors }) => { /* ... */ }, { errors: { gone: err.notFound() } });
a.onDeadLetter("chargeFailed", Charge, ($, { payload }) => { /* payload.error, payload.attempts */ });A failed job, whether from a declared error, an integration failure, or a broken invariant, is retried with exponential backoff, then handed to the dead-letter handler.
Schedules
a.every("cleanup", "1h", ($, { payload }) => { /* payload.firedAt */ });
a.every("digest", "mon 09:00", ($) => { /* ... */ }, { tz: "Europe/Berlin" });
a.every("standup", "weekdays 08:30", ($) => { /* ... */ }, { tz: "America/New_York" });
a.cron("report", "0 6 1 * *", ($) => { /* 06:00 on the 1st */ }, { tz: "UTC" });Calendar schedules follow the time zone across daylight-saving changes. Missed ticks aren't back-filled: a schedule runs at most once per due time.
Walking all rows
A job can't scan an unbounded table, but a schedule can walk it in batches:
a.every("expireTrials", "1d", ($, { payload }) => {
$.forEach(payload.batch, (org) => {
$.when(org.trialEndsAt.lt(payload.firedAt), () => org.update({ plan: "free" }));
});
}, { scan: { entity: Org, index: "byPlan", prefix: ["trial"], batch: 100 } });Each batch is a separately bounded run, chained through the outbox.
Realtime channels
const Feed = a.channel("feed", { key: { orgId: t.string().max(64) }, message: { text: t.string().max(200) } });
Feed.publish({ orgId: "acme" }, { text: "deployed" }); // in a route or handler, after commitDurable workflows
A workflow is a process that spans many steps and possibly days: charge, reserve stock, wait for approval, ship, and refund if anything fails. Each step is its own bounded transaction. State is carried between steps, steps can wait for signals with timeouts, failed steps are retried and then compensated, and every instance is guaranteed to finish.
Declare it
const Fulfill = a.workflow("fulfill", {
input: { orderId: t.string().max(32) }, // what an instance starts with
state: { chargeId: t.string().max(64), reservationId: t.string().max(64) }, // carried between steps
signals: { approved: { by: t.string().max(64) } }, // what it can wait for
maxSteps: 10, // every instance finishes
retries: 2,
backoff: "1s",
});Add steps
A step ends by choosing what's next:
w.next(step, { delay?, state? })w.waitFor(signal, { timeout, onSignal, onTimeout, state? })w.complete(result?)
A step that ends without one completes the workflow.
Fulfill.step("charge", (_, w) => {
const order = Order.getOrFail({ orderId: w.input.orderId }, w.errors.gone);
const charge = payments.call("charge", { orderId: order.orderId, amountCents: order.amountCents });
w.next("reserve", { state: { chargeId: charge.chargeId } });
}, { errors: { gone: err.notFound() } });
Fulfill.step("reserve", (_, w) => {
const stock = inventory.call("reserve", { orderId: w.input.orderId });
w.waitFor("approved", {
timeout: "1d",
onSignal: "ship",
onTimeout: "refund",
state: { reservationId: stock.reservationId },
});
}, { onFail: "refund" }); // compensation after the last retry
Fulfill.step("ship", ($, w) => {
mail.send("send", { to: "warehouse", subject: $.fmt`Ship ${w.input.orderId}, approved by ${w.signals.approved.by}` });
w.complete({ shipped: true });
});
Fulfill.step("refund", ($, w) => {
$.when(w.state.chargeId.isSet(), () => payments.send("refund", { chargeId: w.state.chargeId.ifNull("") }));
w.complete({ shipped: false, reason: w.error.ifNull("not approved in time") });
});Inside a step, w gives you:
w.input, read-onlyw.state, where every field may benulluntil a step sets itw.signals, holding the signal that resumed the stepw.error, the failure that triggered a compensation stepw.instanceId
Use it from routes
a.route("POST /orders")
.body({ orderId: t.string().max(32), customerId: t.string().max(64), amountCents: t.int().min(1) })
.handle((_, { body }) => {
Order.insert(body);
return { started: Fulfill.start(body.orderId, { orderId: body.orderId }) }; // false if it exists
});
a.route("POST /orders/:orderId/approve").handle(($, { params }) => {
Fulfill.signal(params.orderId, "approved", { by: $.caller });
});
a.route("GET /orders/:orderId/fulfillment").handle((_, { params }) => {
const run = Fulfill.get(params.orderId); // { status, step, state, result, error, steps } | null
return { status: run.status, step: run.step, result: run.result };
});
a.route("DELETE /orders/:orderId/fulfillment").handle((_, { params }) => Fulfill.cancel(params.orderId));The key you start an instance with (an order id, a user id) is how you find, signal, and cancel it later. Starting is idempotent: a second start with the same key returns false.
Guarantees
- Atomic steps. A step's writes, its state changes, and scheduling the next step commit together. A crash between steps loses nothing, and a step never half-runs.
- Exactly-once transitions. Every scheduled step is numbered. A stale entry, like a timeout after the signal already came or a step of a cancelled instance, is recognized and dropped.
- Early signals work. A signal that arrives before the instance waits for it is kept, and used as soon as it waits.
- Bounded, and guaranteed to finish. Each step is checked against the job limits, like any handler.
maxStepscaps how many steps an instance runs, so loops between steps still end. - Journaled calls. Integration calls in a step aren't repeated when its transaction re-runs after a conflict.
Instance statuses are running, waiting, completed, failed (a step failed with no onFail, or maxSteps ran out), and cancelled.
How it compares
Code-replay workflow engines require deterministic workflow code and re-run it to rebuild state. Aleph workflows are declared steps with explicit, typed state, so nothing is replayed and determinism isn't something you have to maintain.