Once
Competing workers, visibility leases, retries, dead letters, and per-key ordering for signal.once.
signal.once processes background work with competing consumers — exactly one worker claims each
message; failed attempts retry, then dead-letter.
For developers shipping jobs on okengine — declare the Signal, bind on(signal, flow), emit with
fx.emit.
The one rule
Physics live on the Signal (retries, deadLetter, optional). The Flow is the subscriber.
Visibility lease defaults to 30s — an inflight worker that dies is reclaimed on the next
claim.
once — claim, lease, reclaim
leaseMs · 30s defaultpendingworker-awaiting to claim
Claim sets
lockedBy+leaseExpiresAt. Finish side effects before the lease ends — or another worker may reclaim while you still run.worker-bidle until lease expires
Next claim query takes
pendingor expiredinflight. Make the handler idempotent.
Smallest Example
One handle, three independent uses
emailTask is a shared const. Declare it, bind a worker, and emit — different files, any order.
Binding is not "step 2" after declare; emit is not "step 3".
Declare
import { signal } from "okengine";
import { z } from "zod";
export const emailTask = signal.once("tasks.email", {
schema: z.object({ to: z.string().email(), body: z.string() }),
retries: 3,
deadLetter: true,
});Bind a worker
import { on, flow } from "okengine";
import { emailTask } from "@/signals/email";
import { rawEmail } from "@/channels/email";
export const processEmail = on(
emailTask,
flow("workers.email", {
do: async ({ to, body }, fx) => {
await fx.send(rawEmail, { to, body });
},
}),
);Emit
await fx.emit(emailTask, { to: "alice@example.com", body: "Welcome" });The emit resolves when the outbox commits. The worker runs asynchronously — the producer does not
wait for fx.send to finish.
Competing consumers (once vs broadcast)
This is the most common mix-up. signal.once is a competing-consumer work queue: exactly one
worker claims each message. It is not fan-out.
Three differently-named Flows bound to the same once signal:
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";
export const charge = on(
orderPlaced,
flow("orders.charge", {
do: async ({ orderId }, fx) => {
await fx.call(chargeOrder, { orderId });
},
}),
);
export const ship = on(
orderPlaced,
flow("orders.ship", {
do: async ({ orderId }, fx) => {
await fx.call(shipOrder, { orderId });
},
}),
);
export const notify = on(
orderPlaced,
flow("orders.notify", {
do: async ({ orderId }, fx) => {
await fx.call(notifyOrder, { orderId });
},
}),
);await fx.emit(orderPlaced, { orderId: "ord_99", amount: 150, userId: "usr_1" });| Expectation | Real result |
|---|---|
| All three Flows run | No. Exactly one of the three runs |
Always orders.charge (first on) | No. The winner is whichever claim lands first — not a fixed Flow |
| A sticky assignment to one Flow | No. There is no owner — only an exclusive claim per message |
Consequence: if every bound Flow should independently receive its own copy, use
signal.broadcast. That is the correct fix for this exact
mistake.
Two or more different Flow definitions on the same once signal fail OKE1071.
Cause: Once signal "{signal}" is bound to more than one Flow ({flows}).
Fix: Use signal.broadcast if each flow should independently receive this event, or bind only one flow if these should compete for the same work.
Load-balancing competing consumers is the same Flow on many process replicas — still one
on() in source. That case is not a second Flow definition.
Progressive Patterns
From a minimal job to ordered partitions and DLQ inspection:
Defaults are retries: 3, deadLetter: true, optional: false:
import { signal } from "okengine";
import { z } from "zod";
export const orderPlaced = signal.once("orders.placed", {
schema: z.object({
orderId: z.string(),
amount: z.number(),
userId: z.string(),
}),
});import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";
export const fulfill = on(
orderPlaced,
flow("orders.fulfill", {
do: async ({ orderId }, fx) => {
await fx.call(chargeAndShip, { orderId });
},
}),
);Delivery Reference
| Surface | Signature | Purpose |
|---|---|---|
| Declare | signal.once(name, options?) | Competing queue — one claim per message |
| Bind | on(signalHandle, flow) | Worker Flow; payload is do's input |
| Emit | fx.emit(signal, payload?, { key? }) | Enrol in the outbox; records emits |
| Inspect DLQ | fx.deadLetters(signal) | Exhausted messages; records reads signal:<name> |
| Need | Use instead |
|---|---|
| Every active subscriber gets a copy | broadcast |
| Browser / SSE resume after disconnect | live |
| Durable multi-step work with a journal | Durable Workflows |
Options for once
Optional second argument to signal.once. Delivery is the helper name — not an option.
| Option | Type | Default | Meaning |
|---|---|---|---|
schema | Standard Schema | omitted | Emit contract — validated at fx.emit (OKE1250). Workers inherit payload typing. |
retries | number | 3 | Extra attempts after the first (retries + 1 total) |
deadLetter | boolean | true | Keep exhausted messages; not a queue name |
optional | boolean | false | Allow emit with zero subscribers |
description | string | the name | Console / docs blurb |
retention is a type error on signal.once — that option is live-only:
signal.once("…"): retention is only valid with signal.liveConsequence: deadLetter is a boolean flag, not a DLQ signal name string.
Binding Workers
Each worker binds with on(signalHandle, flow(...)). The Signal carries delivery physics; the
Flow is only the handler.
Name the Signal and attach retry / DLQ / schema policy:
import { signal } from "okengine";
import { z } from "zod";
export const emailTask = signal.once("tasks.email", {
schema: z.object({ to: z.string().email(), body: z.string() }),
retries: 3,
deadLetter: true,
});Emit
Before any worker runs, fx.emit validates schema (when set) and enrols the message in the
outbox. The call resolves on commit — not when the handler finishes.
import { on, flow, http } from "okengine";
import { z } from "zod";
import { orderPlaced } from "@/signals/orders";
export const create = on(
http.post({
in: z.object({ userId: z.string(), amount: z.number() }),
}),
flow({
do: async ({ userId, amount }, fx) => {
const orderId = fx.id();
await fx.emit(orderPlaced, { orderId, amount, userId }, { key: userId });
return { orderId };
},
}),
);| Call | Records | Meaning |
|---|---|---|
fx.emit(signal, payload?) | emits | Competing pool — no ordering |
fx.emit(signal, payload, { key }) | emits | Per-key FIFO for that (signal, key) |
fx.deadLetters(signal) | reads signal:<name> | Inspect dead messages |
parentRunId is stamped automatically from the producer run for Console trace chains — you do
not set it by hand in app code.
Lease and reclaim
Detailed section
If you only need the default 30s lease, jump to Ordering. Claims set lockedBy +
leaseExpiresAt. There is no background sweeper — reclaim happens lazily on the next claim after
expiry.
| Concept | Default | Meaning |
|---|---|---|
| Visibility lease | 30_000 ms | How long an inflight claim holds the message |
| Reclaim | next claim after expiry | Another worker may take the same message |
| Status path | pending → inflight → delivered | dead | Operator inspect / Console |
Consequence: treat every once handler as at-least-once. Prefer idempotent do bodies
(or durable steps for side effects that must not double-fire).
Eligible messages are pending unlocked, or inflight whose leaseExpiresAt has passed. Claim
sets status to inflight, stamps lockedBy + leaseExpiresAt, and increments attempts before
the handler runs — so a crash mid-handler leaves a reclaimable row.
There is no timeout daemon. After the lease expires, the next drain/claim may hand the same message to another (or the same) worker. Slow handlers that outlive the lease can overlap with a reclaim — keep side effects short or journal them.
Successful do → delivered. Exhausted retries with deadLetter: true → dead. With
deadLetter: false → delivered and nothing in the DLQ.
The 30s default lives on the signal bus open options. App authors do not pass leaseMs on
signal.once(…). Tests and custom runtimes may override it when opening the bus.
Ordering
Detailed section
Partition with {key} only when you need per-tenant / per-user FIFO. Unkeyed messages stay a
competing pool and may run concurrently.
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";
export const shipOrder = on(
orderPlaced,
flow("orders.ship", {
do: async ({ orderId }, fx) => {
await fx.call(fulfillOrder, { orderId });
},
}),
);await fx.emit(orderPlaced, { orderId: "ord_1", userId: "usr_1" }, { key: "usr_1" });| Emit | Concurrency | Order |
|---|---|---|
No key | Competing — may overlap | None |
Same key | Serialized by lease | FIFO for that key |
| Different keys | May overlap | Independent |
No two messages sharing (signal, key) are claimed while one holds an unexpired lease. When the
first completes (or its lease expires and is reclaimed), the next same-key message becomes
eligible — emission order is preserved for that key.
Omit key for maximum parallelism across workers. There is no global FIFO across unkeyed messages
— only competing claim exclusivity per message.
Retries and dead letters
Detailed section
If you only need defaults (retries: 3, deadLetter: true), jump to Idempotency. Retries requeue
immediately — there is no delay backoff between attempts.
Handler invocations = retries + 1. On each failure the bus records a typed
{ code, message, at, attempt } reason. When attempts exceeds retries and
deadLetter: true, status becomes dead.
Each failed attempt appends a SignalFailureReason:
| Field | Meaning |
|---|---|
code | Machine-readable failure code |
message | Human-readable detail |
at | Epoch-ms when the attempt failed |
attempt | 1-based attempt number that failed |
The full history survives on the dead-letter entry for operator inspect.
Exhausted messages are marked delivered instead of entering the DLQ. Use when dropping is
acceptable and you do not want operator replay.
Requires a bound signal runtime and effects.reads including signal:<name>.
Cross-signal reads throw OKE1001. Without a runtime:
fx.deadLetters requires a bound signal runtime.
Returned entries include payload, attempts, failures, key, createdAt, and
status: "dead".
Invalid payloads fail at fx.emit with OKE1250 before any worker runs. The Signal's
schema is an emit contract (like Channel schema at fx.send) — distinct from HTTP
invoke contracts on http.* / call / mcp.tool.
Cause: "{resource}": {detail}.
Zero subscribers + optional: false → OKE1240.
Cause: Flow "{flow}" emits signal "{resource}" with no subscriber.
Fix: add on(signal, …) or mark { optional: true }.
Idempotency
Lease reclaim and retries mean a handler can run more than once for the same message.
| Approach | When |
|---|---|
Idempotent do | Side effects are safe to repeat (upsert, set-once flags) |
durable: true + fx.step | Multi-step work that must not double-fire |
| Short handlers | Finish before the 30s lease so reclaim does not overlap |
import { on, flow } from "okengine";
import { syncPayment } from "@/signals/payments";
export const runSync = on(
syncPayment,
flow("payments.sync", {
durable: true,
do: async ({ chargeId }, fx) => {
await fx.step("charge", async () => {
await fx.call(applyCharge, { chargeId });
});
await fx.step("receipt", async () => {
await fx.call(sendReceipt, { chargeId });
});
},
}),
);See Durable Workflows.
Troubleshooting
Confirm on(signalHandle, flow) uses the same declared handle (or the same name) and is adopted
into the app. Check OKE1240 if the emit itself threw. Live Signals are not workers — use Once
or Broadcast.
Lease reclaim after a crash or slow handler is expected at-least-once physics. Make do
idempotent, or journal side effects with durable: true + fx.step.
That is broadcast physics, not once. Two different Flow definitions on one once signal now fail
OKE1071 at boot. If you need every Flow to run, switch the declaration to
signal.broadcast.
Cause: Once signal "{signal}" is bound to more than one Flow ({flows}). Use signal.broadcast
if each Flow should independently receive this event, or bind only one Flow. Replicas of one Flow
are still one on() in source.
Cause: A signal flow on "{trigger}" has no name.
Fix: pass an explicit name — on(handle, flow("workers.email", { do })).
Wait for the 30s lease and the next drain/claim. There is no separate timeout daemon. A handler still running past the lease can overlap with a reclaim — shorten the work or journal it.
Cause: "{resource}": {detail}. Align the payload with schema — the worker never started.
Cause: Flow "{flow}" emits signal "{resource}" with no subscriber.
Add a subscriber or set { optional: true } on the Signal.
Cause: Flow "…" reads "signal:…" without declaring it.
Add effects.reads: ["signal:<name>"] (or the matching signalReadRef) on that Flow.
deadLetter is boolean (default true). There is no separate named DLQ signal string — inspect
with fx.deadLetters(signal).
Drop retention on signal.once, or switch the helper to signal.live.
Learn more
- Signal Overview — delivery matrix and drivers
- Broadcast — fan-out without leases
- Live — retained SSE tapes
- Consumers —
on(signal)next to Clock / CDC - Workflows —
durable+fx.stepfor idempotent side effects - fx —
fx.emit,fx.deadLetters - Errors — OKE1071 · OKE1072 · OKE1240 · OKE1250 · OKE1001