Guide · 09

Run a pool of workers.

A complete, tested job worker. Start as many copies as you like: they share one queue, and when one dies, another finishes its jobs.

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.
  • If a worker dies, its lease runs out and the next claim takes the job again, with attempt one 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. Keep the rest: it handles retries, lost replies, outages and shutdown. Download job-worker.ts.

docs/job-worker.ts · complete and tested
import { FlowerClient, FlowerError } from "@flower-js/sdk";
import type { Json } from "@flower-js/sdk";
import type { Claim } from "@flower-js/sdk/temporal";
import { randomUUID } from "node:crypto";
import { hostname } from "node:os";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";

// A worker for the job 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 };
}

export interface WorkerOptions {
  owner: string;   // Unique per process; shown on each job it holds.
  lanes: number;   // Jobs this process runs at the same time.
  leaseMs: number; // Longer than your slowest job. Leases can't be extended.
  work: typeof work;
  log?: (line: string) => void;
}

type Client = Pick<FlowerClient, "mutate" | "watch">;
const MARGIN_MS = 1_000;    // Finish this long before the lease ends.
const REQUEST_MS = 10_000;  // Timeout for each request to Flower.

// Network errors, timeouts, 429 and 5xx are worth retrying; 4xx answers are final.
const transient = (error: unknown) => !(error instanceof FlowerError) ||
  [408, 425, 429].includes(error.status) || error.status >= 500;
const backoff = (attempt: number) => Math.min(30_000, 250 * 2 ** Math.min(attempt, 7)) * (0.5 + Math.random() / 2);
const reason = (error: unknown) => String((error as { cause?: { code?: string } })?.cause?.code ?? (error as Error)?.message ?? error);

// Runs until `stop` aborts, then finishes the jobs it holds and returns.
export async function runWorker(client: Client, options: WorkerOptions, stop: AbortSignal) {
  const { owner, leaseMs, log = console.log } = options;

  // Retry one request with the SAME request ID, so a lost reply can't apply it twice.
  async function send<T>(name: string, args: object, until: number): Promise<T> {
    const requestId = randomUUID();
    for (let attempt = 0; ; attempt++) {
      try {
        return (await client.mutate<object, T>(name, args, { requestId, signal: AbortSignal.timeout(REQUEST_MS) })).value;
      } catch (error) {
        if (!transient(error) || Date.now() + backoff(attempt) >= until) throw error;
        await sleep(backoff(attempt));
      }
    }
  }

  // Wait until jobs.ready is true. A fresh watch starts with a fresh snapshot,
  // so work that arrived while this lane was busy is never missed.
  async function waitForWork(): Promise<void> {
    for (let attempt = 0; !stop.aborted; attempt++) {
      try {
        const connection = AbortSignal.any([stop, AbortSignal.timeout(60_000)]); // Also repairs silent stalls.
        for await (const { value } of client.watch<null, boolean>("jobs.ready", null, { signal: connection })) {
          if (value) return; // Leaving the loop closes the watch.
          attempt = 0;
        }
      } catch (error) {
        if (stop.aborted) return;
        if (!transient(error)) throw error;
      }
      await sleep(backoff(attempt), undefined, { signal: stop }).catch(() => {});
    }
  }

  async function runJob(lane: number, job: Claim, deadline: number): Promise<void> {
    const lease = { id: job.id, owner: job.owner, token: job.token, ...(job.history && { history: job.history }) };
    const timeLeft = deadline - Date.now();
    if (timeLeft <= 0) return log(`lane ${lane}: skipped ${job.id}, its lease ran out before it started`);
    log(`lane ${lane}: working on ${job.id} (attempt ${job.attempt})`);
    const timeout = AbortSignal.timeout(timeLeft);
    let outcome: [string, object];
    try {
      outcome = ["jobs.complete", { ...lease, result: await options.work(job, timeout) }];
    } catch (error) {
      const message = timeout.aborted ? `Ran out of time after ${timeLeft} ms; raise the lease or split the job` : reason(error);
      outcome = ["jobs.fail", { ...lease, error: { message } }];
    }
    try {
      await send(outcome[0], outcome[1], deadline + MARGIN_MS);
      log(`lane ${lane}: ${outcome[0] === "jobs.complete" ? "completed" : "failed"} ${job.id}`);
    } catch (error) {
      if (error instanceof FlowerError && /LEASE_LOST/.test(error.message)) {
        log(`lane ${lane}: lost ${job.id}, its lease ran out first; another worker will redo it`);
      } else {
        log(`lane ${lane}: couldn't report ${job.id} (${reason(error)}); if it wasn't saved, it runs again after its lease`);
      }
    }
  }

  async function lane(lane: number): Promise<void> {
    for (let failures = 0; !stop.aborted;) {
      await waitForWork();
      // Claim until the queue is empty, then go back to waiting.
      while (!stop.aborted) {
        const sentAt = Date.now(); // The lease can't have started before this.
        let job: Claim | null;
        try {
          job = await send<Claim | null>("jobs.claim", { owner, leaseMs }, sentAt + leaseMs);
          failures = 0;
        } catch (error) {
          if (!transient(error)) throw error; // A bad setting or a missing method: fix it, don't spin.
          log(`lane ${lane}: claim failed (${reason(error)}); retrying soon`);
          await sleep(backoff(failures++), undefined, { signal: stop }).catch(() => {});
          break;
        }
        if (job === null) break;
        await runJob(lane, job, sentAt + leaseMs - MARGIN_MS); // Finish what we claimed, even when stopping.
      }
    }
  }

  await Promise.all(Array.from({ length: options.lanes }, (_, i) => lane(i + 1)));
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const stop = new AbortController();
  const options: WorkerOptions = {
    owner: process.env.WORKER_ID ?? `${hostname()}-${process.pid}`,
    lanes: Number(process.env.WORKER_LANES ?? 4),
    leaseMs: Number(process.env.WORKER_LEASE_MS ?? 10_000),
    work,
    log: (line) => console.log(`${new Date().toTimeString().slice(0, 8)} ${line}`),
  };
  if (!(Number.isInteger(options.lanes) && options.lanes > 0 && Number.isInteger(options.leaseMs) && options.leaseMs > MARGIN_MS)) {
    throw new Error("WORKER_LANES must be a positive integer, and WORKER_LEASE_MS an integer above 1000");
  }
  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();
    });
  }
  console.log(`Worker ${options.owner} (pid ${process.pid}) running ${options.lanes} lane${options.lanes === 1 ? "" : "s"}`);
  const client = new FlowerClient(process.env.FLOWER_URL ?? "http://127.0.0.1:7101");
  await runWorker(client, options, stop.signal);
}
Worker settings
Environment variableDefault
FLOWER_URLhttp://127.0.0.1:7101. Any member works; writes are forwarded to the leader.
WORKER_LANES4 jobs at a time.
WORKER_LEASE_MS10,000 ms. At most the queue’s maxLeaseMs, 30,000 in the example.
WORKER_IDHost 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

  1. 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
  2. Start two workers, one lane each so there is time to watch:
    Terminal 2
    WORKER_ID=alpha WORKER_LANES=1 node docs/job-worker.ts
    Terminal 3
    WORKER_ID=beta WORKER_LANES=1 node docs/job-worker.ts
  3. 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
  4. While they run, crash alpha: in terminal 1, run kill -9 with the pid alpha printed when it started. Note the job it was working on. (Ctrl+C would let it finish that job first.)
  5. About ten seconds later, beta logs working on job-… (attempt 2) for that job. Check it:
    Terminal 1
    node sdk/cli.ts query jobs.get '"job-7"'
    # "state": "completed", "result": { "attempt": 2, "handledBy": "beta" }

Run node sdk/cli.ts watch jobs.backlog alongside to see the wait time rise and fall.

Pick the numbers

Sizing a worker pool
SettingStart with
Lease length2–3× your slowest job. Leases can’t be extended, and a job still running when its lease ends is stopped and marked failed. A dead worker’s job also waits this long before another worker takes it.
Lanes per processFor jobs that mostly wait on other services: 4–32. For CPU-heavy jobs: one per core.
ProcessesAt least two, on different machines, so one crash or deploy doesn’t stop the work.
Worker IDThe default. It is stored on every job the worker holds, so you can see who has what.

When things go wrong

Failures and what the worker does
What happensWhat the worker does, and what you do
A worker crashes mid-jobAfter the lease, another worker runs the job again. Make jobs safe to run twice: pass job.id to external services as an idempotency key.
A job runs past its leaseThe worker aborts work()’s signal and marks the job failed with “Ran out of time”. Raise the lease or split the job.
A job throwsThe worker marks it failed with the error message. Failed jobs never retry on their own: fix the cause, then call jobs.retry.
A reply from Flower is lostThe worker retries with the same request ID, and Flower applies it once. Nothing to do.
Flower is down or has lost its majorityThe worker retries with backoff and reconnects its watch. A report that can’t land before the lease ends is dropped, and the job runs again later. Keep leases longer than a leader election, which takes a few seconds.
A paused worker wakes after its leaseIts report gets LEASE_LOST and is ignored. 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 job has been waiting. The example app has a query for it:

examples/workers.ts
// Add workers when the oldest waiting job keeps getting older.
export const jobsBacklog = query("internal.jobs.backlog", (ctx) => {
  const oldest = ctx.range(jobs.records.by("pending").range({ prefix: ["", "pending"], limit: 1 })).rows[0];
  return { oldestWaitingSeconds: oldest ? Math.floor((ctx.now() - oldest.value.createdAt) / 1000) : 0 };
});

If oldestWaitingSeconds keeps growing, add workers, as long as the services they call can take the extra load. If it stays near zero, you have enough.