Operations¶
Deploying workers¶
A worker is the same application, started differently:
--queue may be repeated, highest priority first, and overrides the
application's own configuration. --consumer names the worker.
Consumer names must be unique per running worker. Redis groups pending
entries by consumer name, and reclaim uses that name to tell its own work from a
peer's. The default appends a random suffix to consumer_prefix per process
start, which is correct for any number of replicas; leave consumer unset
unless you have a reason.
The hazard is setting it in code. QueueConfig(consumer="worker") is one
literal shared by every replica of the deployment, and nothing fails loudly:
each worker skips entries pending under its own name as work it is running
itself, so when one replica dies, the others never reclaim what it held. Its
entries sit in the pending list until some worker restarts and takes them back
as its own orphans.
To name workers yourself, take the name from the environment rather than the
config literal. --consumer overrides the config, and under Kubernetes the pod
name is unique among running pods and arrives through the downward API:
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
command: ["litestar", "workers", "run", "--consumer", "$(POD_NAME)"]
A name that survives a container restart inside the same pod is an advantage rather than a risk: the restarted worker finds the entries left under its name at startup and takes them back at once, instead of waiting for them to be reclaimed.
What starts with it¶
The same application means the same lifecycle. A worker enters
Litestar.lifespan() before it consumes anything and leaves it after draining,
so on_startup hooks, on_shutdown hooks and custom lifespan managers all run
in a worker process. A dependency that closes over something opened there — a
database pool, an HTTP client, a broker connection — resolves to an opened one,
and a failure to open it surfaces at startup rather than on the first job.
The queue is the outermost of those managers, so a manager the application
registers has it open on the way in and on the way out — it can enqueue at
startup and drain on shutdown. on_shutdown hooks are the exception: Litestar
puts them on its exit stack before any context manager, so they run last, after
the queue has closed, and a hook that reaches for plugin.transport there is
told so. Shutdown work that needs the queue belongs in a lifespan manager.
Declining it¶
Entering the lifespan happens in every worker replica, and part of a lifespan is often meant for one process only — starting a scheduler, warming a cache, claiming a lease. Guard that part where it is written; the rest of the lifespan, the pool a task needs among it, still has to run:
import os
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from litestar import Litestar
@asynccontextmanager
async def run_the_scheduler(app: Litestar) -> AsyncGenerator[None]:
if os.environ.get("ROLE") != "web":
yield
return
async with scheduler:
yield
run_app_lifespan=False is the blunter instrument, for a lifespan that is
web-only in its entirety:
from smallage.litestar import QueueConfig, TaskRegistry
QueueConfig(registry=TaskRegistry(), run_app_lifespan=False)
A worker that declines opens the queue's own connections and nothing else, and runs none of the application's hooks — including the ones its tasks depend on, which is why it is the second answer and not the first.
Connections¶
Each worker opens two clients. A blocking XREADGROUP occupies its connection
for the whole block window, and an ack or a liveness refresh queued behind it
makes a healthy worker look dead to its peers.
The reader's socket_timeout is derived from the block window: below it every
healthy blocking read would die, and above it is the only thing that surfaces a
connection hung by a failover.
Health¶
QueuePlugin.health() answers everything a readiness probe needs. The plugin
registers no route for it: where a probe lives, whether it is public, and what
else belongs in the same response are the application's to decide, and it would
be an odd plugin that could fail your startup over a path it added itself.
from litestar import Litestar, Response, get
from smallage.litestar import QueueConfig, QueueHealth, QueuePlugin, TaskRegistry
queue = QueuePlugin(QueueConfig(registry=TaskRegistry()))
@get("/health/queue")
async def health() -> Response[QueueHealth]:
report = await queue.health()
# A probe reads the status code, not the body. Answering 200 while unhealthy
# is a probe that can never fail.
return Response(report, status_code=200 if report.healthy else 503)
app = Litestar(route_handlers=[health], plugins=[queue])
The handler closes over the plugin rather than taking it as an argument: the
plugin registers no dependency of its own, so a plugin: QueuePlugin parameter
would be read as a query parameter and the request answered with a 400. Folding
the queue into a health endpoint the application already has works the same way
— await queue.health() beside whatever else that endpoint reports.
A worker serves nothing. It has no server, and this library does not start
one: an HTTP probe against a worker deployment is that deployment's own decision
— which port, which interface, behind what — and health() is the whole of what
it needs from here, computed exactly as it is for a web process. Liveness has an
answer that needs no HTTP at all: a worker refreshes a key per entry it holds,
and a peer reclaims what stops being refreshed — see Retries.
The response:
{
"namespace": "lrs",
"group": "workers",
"queues": ["default"],
"lag": 0,
"stats": {
"handled": 12, "failed": 1, "retried": 1, "buried": 0,
"unknown_task": 0, "reclaimed": 0, "skipped_duplicate": 2, "in_flight": 3
}
}
stats are this process's own counters, so a web process reports zeros — it
runs no tasks. unknown_task is the one to watch during a rollout: a few are
normal while two versions overlap, and a number that keeps climbing after the
rollout finished means a task name was removed rather than renamed.
lag is the consumer group's depth from XINFO GROUPS. Redis reports it as
null when it cannot reconcile its counters after entries were deleted — and a
missing reading is not a zero-depth queue, so healthy is false in that case.
XLEN is meaningless here: acked entries are deleted, so the stream length is
near zero regardless of backlog.
Shutdown¶
SIGTERM stops new reads and lets in-flight work finish. When
drain_timeout_s runs out, the watchdog cuts it off. A second signal means
"now".
Work cancelled by the watchdog is not an application failure: it stays in the PEL unacked, and its liveness key is dropped so a peer takes it immediately rather than waiting out the TTL.
Worst-case shutdown is one block window plus drain_timeout_s. Size
terminationGracePeriodSeconds against that sum, not against the drain timeout
alone:
An application-level bare except: swallows CancelledError and the worker will
never exit. ruff's B and E722 rules catch that in this project's own code.
Failure modes worth knowing¶
A dropped connection does not kill the worker. Every loop logs the error,
pauses for recovery_interval_s and carries on; redis-py reconnects on the next
command, and records already taken stay in the PEL to be reclaimed. This is what
a Sentinel failover looks like from inside a worker.
Rescheduling can fail too, on the same connection that just died. When it does, the entry is left unacked — the safe outcome, because reclaim is exactly the mechanism for entries whose owner stopped responding.
A dead worker's work comes back in tens of seconds, not one TTL. Reclaim
needs both the entry's alive key gone and the entry idle for min_idle_ms since
it was delivered; the heartbeat refreshes the key but not the idle time. At the
defaults, a job that had been running for a while returns 20 to 35 seconds after
its worker dies — the TTL expiring plus a reclaim pass — and one that had just
started returns after up to 65, because the 60 s floor has not yet run out.
min_idle_ms is what bounds the worst case.
A worker never reclaims what Redis just served it. An entry joins the
pending list the moment Redis executes XREADGROUP, before the reply reaches
the worker, so ownership rather than timing is what decides. Entries left under
the same consumer name by a previous run are snapshotted at startup and taken
back once.
Stream growth¶
Acking deletes the entry, so a healthy stream stays near empty. A background
XTRIM MINID by retention_ms removes what acking did not, floored at the
oldest unacknowledged entry — MINID has the same hazard as MAXLEN once
pending work falls outside the retention window.
Consumer names¶
The group grows too, and for a longer-lived reason: a worker registers its
consumer name on its first read, a name is derived per process start, and Redis
expires none of them. Four replicas across a year of daily deploys would leave
some fifteen hundred dead names in the group — enough to make XINFO CONSUMERS
useless exactly when an operator reaches for it.
The same loop that trims sweeps them: a consumer holding no pending entries and
idle longer than consumer_idle_ms — an hour by default — is removed. A live
worker touches the group at least every block_ms, so an hour is far above
anything a running process produces, and deleting one early would be harmless
anyway because Redis recreates it on the next read.
Holding nothing is the hard condition, not idleness. Redis makes the pending entries of a deleted consumer unclaimable, so a consumer that still owns work is never touched however long it has been quiet — that is where reclaim goes looking for a dead worker's orphans. The check and the deletion are one Lua script rather than two commands, because between them a live worker could take an entry and the sweep would destroy it.
Redis topologies¶
Standalone, Sentinel and Cluster are all covered by the test suite. Cluster is why every key of a namespace carries the same hash tag; if you shard by namespace, each namespace occupies its own slot.
Because a namespace lives in one slot, resharding moves the entire queue — stream, liveness keys, scheduler ZSET and all — in a single migration. A worker consuming through one is covered too: the test moves the slot under an in-flight job and asserts the worker follows.
Cluster and Sentinel are alternative high-availability models — Cluster does its own failover and does not use Sentinel — so there is no deployment running both.