# Workers

> Register handlers, size concurrency, tune the lease, and drain active work safely.

Jobs sit in PostgreSQL until a process runs them. That process is a `Worker`: a loop that
claims ready jobs, runs your handlers, and records each result. This page covers everything
between `new Worker(queue)` and a clean shutdown.

## Register handlers and run

`Worker.handle` binds a job type to an async function. Registration chains, so one worker can
serve many types. Each handler receives the payload and a `HandlerContext`, and returns a JSON
result that becomes the job's durable outcome.

<Tabs items={["TypeScript", "Python", "Go"]}>
  <Tab value="TypeScript">
    ```ts
    const worker = new Worker(queue, { queues: ["email", "billing"], concurrency: 5 });
    worker.handle("email.send", async (payload: { to: string }, ctx) => {
      await mailer.send(payload.to, { signal: ctx.signal });
      return { deliveredTo: payload.to };
    });
    await worker.run();
    ```
  </Tab>
  <Tab value="Python">
    ```python
    worker = Worker(connection, queues=["email", "billing"], concurrency=5)

    def send_email(payload: object, context: HandlerContext) -> Json:
        context.cancellation.raise_if_cancelled()
        return mailer.send(payload)

    worker.handle("email.send", send_email)
    worker.run()
    ```

  </Tab>
  <Tab value="Go">
    ```go
    worker, err := workhorse.NewWorker(pool, workhorse.WorkerOptions{
        Queues: []string{"email", "billing"}, Concurrency: 5,
    })
    if err != nil {
        return err
    }
    worker.Handle("email.send", func(
        ctx context.Context, payload any, handler *workhorse.HandlerContext,
    ) (any, error) {
        return mailer.Send(ctx, payload)
    })
    err = worker.Run(ctx)
    ```
  </Tab>
</Tabs>

`run` loops until you stop it; pass an `AbortSignal` as `worker.run(signal)` to tie its
lifetime to your process. `runOnce` performs a single claim-and-run pass and returns whether
it found work — useful in tests and scripts.

Python exposes this lifecycle through synchronous `Worker` and asynchronous `AsyncWorker`.
Both rotate across configured queues, fill a bounded set of slots, and drain active handlers before
`run` returns. `AsyncWorker` uses dedicated native Psycopg or asyncpg query and notification
connections, and its handler context methods are awaitable. Both surfaces share the same claim,
heartbeat, batch, settlement, telemetry, and drain core. Workers in all three languages also
participate in the worker registry.
Python's `handle_batch` follows the same grouping contract described in
[Batch handlers](/docs/batch-handlers).

## Waiting without constant polling

An idle worker listens for committed wake hints and receives only notifications for its configured
queues. Promotion and recovery notify each affected queue separately, so activity on one queue does
not wake workers assigned to another. Notifications wake dispatch without changing the cadence of
maintenance or worker registration, while bounded polling still covers a lost listener or message.
Workers briefly stagger notification-triggered claims. Workers without an active listener back off
after consecutive empty checks and reset that delay when a claim succeeds.

The `HandlerContext` carries the claimed job, its `AbortSignal`, checkpoints, durable waits,
and progress. Every context method writes under the current fence, so handler code cannot
accidentally write as a stale owner after the job moves on.

## The options that matter

<Tabs items={["TypeScript", "Python", "Go"]}>
  <Tab value="TypeScript">
    ```ts
    const worker = new Worker(queue, {
      queues: ["email", "billing"], concurrency: 5, leaseMs: 30_000,
      workerId: "email-worker-1",
    });
    ```
  </Tab>
  <Tab value="Python">
    ```python
    worker = Worker(
        connection,
        queues=["email", "billing"],
        concurrency=5,
        lease_ms=30_000,
        worker_id="email-worker-1",
    )
    ```
  </Tab>
  <Tab value="Go">
    ```go
    worker, err := workhorse.NewWorker(pool, workhorse.WorkerOptions{
        Queues: []string{"email", "billing"}, Concurrency: 5,
        LeaseDuration: 30 * time.Second, WorkerID: "email-worker-1",
    })
    ```
  </Tab>
</Tabs>

- **`concurrency`** caps simultaneous handlers in this one `Worker`. Replicas multiply that
  capacity. When the fleet must share one budget, use
  [concurrency policies](/docs/concurrency-policies); for a shared start rate, use
  [rate limits](/docs/rate-limits).
- **`leaseMs`** is how long a claim owns a job before the database may recover it. One
  background timer batches every active lease owned by the worker — every `heartbeatMs`,
  defaulting to a third of `leaseMs`. A shorter lease means faster
  recovery after a crash; a longer one tolerates worse network pauses.
- **`workerId`** identifies the lease owner. Leave it generated unless a process manager
  guarantees uniqueness: two live workers sharing one identity collide over ownership. The
  default embeds host and pid, so fleet views stay readable.
- **`retryDelayMs`** overrides the persisted retry policy — a number, or
  `(attempt, job) => number | undefined`, where `undefined` defers to the database.
- **`scheduleNamespaces`** and **`scheduleCatchupLimit`** opt this worker into offering
  [recurring schedules](/docs/schedules).

Python uses `schedule_namespaces` and `schedule_catchup_limit` for the same boundary. Go uses
`WorkerOptions.ScheduleNamespaces` and `WorkerOptions.ScheduleCatchupLimit`.

- **`queues`** lets one worker rotate across several queues under the same identity and
  concurrency budget. Use `queue` for one queue; omit both options to use the client default.

Group queues that share handlers and operational policy into one multi-slot worker. Add a second
worker when work needs a different lease, retry override, schedule namespace, or handler registry.

## Process jobs in batches

`Worker.handleBatch` groups claimed jobs of one type when one provider call can process several
payloads efficiently. Every member still occupies one concurrency slot and keeps its own lease,
fence, cancellation signal, progress, retry budget, and final result.

The TypeScript `handleBatch`, Python `handle_batch`, and Go `HandleBatch` forms are shown together
in [Batch handlers](/docs/batch-handlers), including their positional success and failure results.

The callback must return one ordered outcome per member. Batch contexts omit durable waits,
signals, human decisions, and child joins because one member cannot suspend independently inside a
shared invocation. See [Batch handlers](/docs/batch-handlers) for grouping and failure rules.

## Run workers in a dedicated process

Queue depth and HTTP traffic rarely scale together, so production deployments put workers in
their own process. `defineWorkerProcess` describes the adapter and workers; the
`workhorse worker` CLI loads the compiled definition and adds signal handling and supervision.

<Tabs items={["TypeScript", "Python", "Go"]}>
  <Tab value="TypeScript">
    ```ts
    export default defineWorkerProcess({
      adapter: () => createWorkhorseAdapter(adapterOptions),
      workers: [{
        options: { queues: ["email", "billing"], concurrency: 5 },
        configure(worker) { worker.handle("email.send", sendEmail); },
      }],
    });
    ```
  </Tab>
  <Tab value="Python">
    ```python
    with psycopg.connect(DATABASE_URL, autocommit=True) as connection:
        worker = Worker(connection, queues=["email", "billing"], concurrency=5)
        worker.handle("email.send", send_email)
        run_worker_process(worker)
    ```
  </Tab>
  <Tab value="Go">
    ```go
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()
    worker, err := workhorse.NewWorker(pool, workhorse.WorkerOptions{
        Queues: []string{"email", "billing"}, Concurrency: 5,
    })
    worker.Handle("email.send", sendEmail)
    err = worker.Run(ctx)
    ```
  </Tab>
</Tabs>

The process owns its database resources, worker loops, optional probes, and shutdown. Compile
the definition before the CLI imports it — the CLI does not install a TypeScript loader.

The first termination signal stops claims and drains active jobs. A second signal or an expired
deadline exits immediately, leaving PostgreSQL to recover any active leases.

Go applications create the same boundary with `signal.NotifyContext`, then pass that context to
`Worker.Run`. A handler panic becomes a recorded attempt failure, so it does not terminate the
worker process or prevent later jobs from running.

## Pause, stop, and drain

Three verbs cover controlled slowdown, and each answers a different situation:

- **`worker.pause()`** stops this worker from claiming while active handlers finish.
  `worker.resume()` lets it claim again; `worker.isPaused()` reads the effective state.
- **`worker.stop()`** drains: no new claims, active handlers run to completion, then `run`
  resolves. On `SIGTERM`, the worker process does this for you.
- **`Admin.setWorkerPaused`** stores an operator pause in `worker_registry`. TypeScript, Python,
  and Go workers pick it up on their next registration refresh, so a dashboard can pause a process
  it does not host. A local resume cannot clear an operator pause, and an operator pause dies with
  the process incarnation it named.

If work must stop durably — surviving worker restarts — pause the queue with
`Admin.pauseQueue`, not the worker.

## Read fleet state carefully

`Worker.runtimeState()` reads a TypeScript worker's local process state. The dashboard and
`Admin.listWorkers` read the durable registry that every runtime refreshes on an interval. Registry
slot counts are recent observations, not synchronous views of another event loop — treat them as a
moment-ago snapshot.

TypeScript configures the cadence with `registryIntervalMs`, Python uses `registry_interval_ms`,
and Go uses `RegistryInterval`. A registration error does not stop dispatch, and the worker keeps
its last remote-pause decision until a later refresh succeeds.

A worker that dies stops refreshing and goes stale in the registry. Automatic maintenance makes
that row eligible for removal after one minute. Its jobs are not lost: their leases expire and
recovery makes them claimable again.

The protocol names make this lifecycle greppable. `claim_many_v1` applies `claim_v1` to each
selected job and writes its `job_runtime` lease. `heartbeat_many_v1` submits every active lease
and extends the accepted `expires_at` values. `recover_expired_v1` makes abandoned work ready.
Workers claim from `workhorse_jobs`, TypeScript configures fallback polling with `pollMs`, and
processes commonly begin graceful drain after `SIGINT`.

## Next

- [Deployment and operations](/docs/operations) — supervise and drain the worker process
- [Batch handlers](/docs/batch-handlers) — share one application call across jobs
- [Cancellation](/docs/cancellation) — make handlers stop cooperatively
- [Schedules](/docs/schedules) — let workers offer recurring namespaces

---

Exact worker options, registry fields, heartbeat behavior, and process lifecycle:
[architecture reference](https://github.com/stablemates/workhorse/blob/main/docs/architecture.md#worker-concurrency-and-lifecycle).
