It runs jobs from the queue in examples/workers.ts, on as many machines as you like. Keeping a result current instead of running jobs? See External workers.
How it works
- A claim gives one worker a job until its lease ends. Flower decides who gets which job, so there is no coordinator or leader to run.
- While your code runs, the worker renews the lease. If a worker dies, renewals stop, the lease runs out, and the next claim takes the job again with
attemptone higher. - A worker that reports after its lease ended gets
LEASE_LOST, and its report is ignored. - Each process runs several lanes. A lane handles one job at a time.
The worker
Replace work() with your job. runQueueWorker does the rest: waiting, claiming, renewing, retrying lost replies, riding out outages, and shutting down cleanly. Download job-worker.ts.
import { FlowerClient } from "@flower-js/sdk";
import type { Json } from "@flower-js/sdk";
import type { Claim } from "@flower-js/sdk/temporal";
import { runQueueWorker } from "@flower-js/sdk/worker";
import { hostname } from "node:os";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
// A worker for the queue in examples/workers.ts. Start as many copies as you
// like, on as many machines as you like: they share the queue, and when one
// dies, the others finish its jobs once its leases run out.
// Your job goes here. It can run more than once (a worker can die after doing
// the work but before reporting it), so pass job.id to external services as an
// idempotency key. Stop when `signal` aborts: the lease is about to end.
export async function work(job: Claim, signal: AbortSignal): Promise<Json> {
await sleep(1_000 + Math.random() * 2_000, undefined, { signal }); // Pretend to call an API.
return { handledBy: job.owner, attempt: job.attempt };
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const stop = new AbortController();
for (const name of ["SIGINT", "SIGTERM"] as const) {
process.on(name, () => {
if (stop.signal.aborted) process.exit(130); // Second signal: quit now; leases expire and others take over.
console.log("Stopping: finishing held jobs. Press Ctrl+C again to quit now.");
stop.abort();
});
}
const time = () => new Date().toTimeString().slice(0, 8);
await runQueueWorker(new FlowerClient(process.env.FLOWER_URL ?? "http://127.0.0.1:7101"), {
queue: "jobs",
work,
signal: stop.signal,
owner: process.env.WORKER_ID ?? `${hostname()}-${process.pid}`,
lanes: Number(process.env.WORKER_LANES ?? 4),
leaseMs: Number(process.env.WORKER_LEASE_MS ?? 10_000),
onEvent: (event) => console.log(time(), `lane ${event.lane}:`, event.type, "job" in event ? event.job.id : "id" in event ? event.id : event.error),
});
}| Environment variable | Default |
|---|---|
FLOWER_URL | http://127.0.0.1:7101. Any member works; writes are forwarded to the leader. |
WORKER_LANES | 4 jobs at a time. |
WORKER_LEASE_MS | 10,000 ms per claim and renewal. At most the queue’s lease.maxMs, 30,000 in the example. |
WORKER_ID | Host name and process ID. Must be unique per process. |
Ctrl+C or SIGTERM stops claiming and finishes held jobs. A second Ctrl+C quits at once; the jobs it held run again after their leases.
Try it: kill a worker
- Start a local node, then deploy the queue from the checkout:Terminal 1 · repository root
npm run build node sdk/cli.ts deploy examples/workers.ts - Start two workers, one lane each so there is time to watch:Terminal 2
WORKER_ID=alpha WORKER_LANES=1 node docs/job-worker.tsTerminal 3WORKER_ID=beta WORKER_LANES=1 node docs/job-worker.ts - Queue 20 jobs:Terminal 1
for i in $(seq 1 20); do node sdk/cli.ts call jobs.enqueue "{\"id\":\"job-$i\",\"payload\":{\"n\":$i}}" \ --request-id "enqueue-job-$i" > /dev/null done - While they run, crash alpha: press Ctrl+\ in its terminal. That kills it without the clean shutdown Ctrl+C would do. Note the job it last claimed.
- Within about ten seconds alpha's lease runs out and the job goes back in line, behind the jobs that were already waiting. Beta logs
claimedfor it when its turn comes. Check it:Terminal 1node sdk/cli.ts query jobs.get '{"id":"job-7"}' # "state": "completed", "result": { "attempt": 2, "handledBy": "beta" }
Run node sdk/cli.ts watch jobs.stats alongside to see the backlog rise and fall.
Pick the numbers
| Setting | Start with |
|---|---|
| Lease length | 10–30 s. Renewal lets jobs outlast it, so it only needs to cover a missed renewal or a leader election. A dead worker’s job waits up to this long before another worker takes it. |
| Lanes per process | For jobs that mostly wait on other services: 4–32. For CPU-heavy jobs: one per core. |
| Processes | At least two, on different machines, so one crash or deploy doesn’t stop the work. |
| Worker ID | The default. It is stored on every job the worker holds, so you can see who has what. |
| Retries | The queue’s retry policy: five attempts with backoff in the example. Keep the job’s own timeouts shorter than the lease. |
When things go wrong
| What happens | What the worker does, and what you do |
|---|---|
| A worker crashes mid-job | Once the lease runs out, another worker runs the job again. Make jobs safe to run twice: pass job.id to external services as an idempotency key. |
| A job throws | The worker fails the job with the error message, and the queue retries it with backoff. After the last attempt it stays failed: fix the cause, then call jobs.retry. |
| Renewals stop landing | Shortly before the lease ends, the worker aborts work()’s signal and fails the job, which is then retried. |
| A reply from Flower is lost | The worker retries with the same request ID, and Flower applies it once. Nothing to do. |
| Flower is down or has lost its majority | The worker retries with backoff and keeps waiting for work (waiting events). A report that can’t land before the lease ends is dropped (unreported), and the job runs again later. |
| A paused worker wakes after its lease | Its report gets LEASE_LOST and is ignored (lost). Anything it already did outside Flower stays done, which is why jobs must be safe to repeat. |
Know when to add workers
Watch how long the oldest ready job has been waiting. The queue's stats method reports it:
for await (const { value } of client.subscribe("jobs.stats", null)) {
const waitingMs = value.oldestReadyAt === null ? 0 : Date.now() - value.oldestReadyAt;
console.log(`oldest ready job has waited ${Math.round(waitingMs / 1000)} s`);
}If that wait keeps growing, add workers, as long as the services they call can take the extra load. If it stays near zero, you have enough. nextAvailableAt says when a delayed job or running lease next makes work available.