Deployment¶
firm is database-backed — the SQL database is the coordination substrate, so there is no broker (no Redis, no RabbitMQ) and no new stateful component to run. The database you already have is the only stateful piece; everything else is stateless and coordinates through it.
Only the queue runs a long-lived process. cache, channel, and audit are in-process
libraries your app calls directly — they add tables, not daemons.
| Module | Runs as | Periodic housekeeping |
|---|---|---|
| queue | a worker process (embedded thread or a separate firm-queue start) |
— |
| cache | in-process library calls | eviction is opportunistic (on write, or an in-process timer); optional firm-cache trim |
| channel | in-process publish/subscribe | optional firm-channel trim |
| audit | in-process writes | firm-audit prune on a schedule (never automatic) |
The database is the only stateful component
Adding firm to an app you already run costs, at most, one extra worker deployment plus a run-once migration — no broker, no cache server, no new datastore.
So there are two ways to run the queue. Pick by scale.
Option A — Embedded (in your app process)¶
The ThreadSupervisor runs every role (worker, dispatcher) as a
background thread inside your existing process. It's a context manager:
from firm.queue import current_runtime
from firm.queue.supervisor import (
DispatcherConfig,
SupervisorConfig,
ThreadSupervisor,
WorkerConfig,
)
config = SupervisorConfig(
workers=[WorkerConfig(queues=("*",), threads=3, poll_interval=0.1)],
dispatchers=[DispatcherConfig()],
)
with ThreadSupervisor(current_runtime(), config):
run_my_app() # requests are served here; jobs drain in the background
Use it when you run a single service at low-to-moderate job volume and want the simplest possible operations — one image, one deployment, nothing extra to schedule.
The trade-offs: web and job work share the same pods (and the GIL), so they compete for CPU and can't scale independently, and a misbehaving job shares a process with request serving. When those bite, move to Option B.
→ Runnable: examples/embedded_worker.py
Option B — A separate worker deployment¶
The ForkSupervisor forks one child process per role and keeps
them alive; it's what the firm-queue start CLI runs. Run it as its own
deployment, using the same image as your app with a different command:
# your web pods enqueue:
uvicorn myapp:app
# your worker pods process (same image, different command):
firm-queue start --mode fork --import myapp.jobs --queues '*' --threads 5
Workers coordinate entirely through the database, so you scale throughput by running more
replicas. There's no leader to elect: the recurring scheduler deduplicates each cron tick across
every replica (a unique index on (task_key, run_at)), so it's safe to run the full stack —
including recurring tasks — on all of them.
Use it when you want to scale jobs independently of web traffic, isolate job crashes from request serving, or saturate more than one core (CPU-bound work scales by processes, not threads, because of the GIL).
See Running on Kubernetes below for the deployment shape.
Running on Kubernetes¶
The DB-backed model keeps the topology small: no StatefulSets or PVCs on the app side, no broker.
| Component | k8s object | Notes |
|---|---|---|
| Database | managed service or an operator (CloudNativePG, …) | the only stateful piece; SQLite can't be shared across pods — use PostgreSQL or MySQL |
| Schema migration | run-once Job (or a Helm/Argo pre-upgrade hook) |
creates + stamps the schema before workers roll out |
| Workers | Deployment |
firm-queue start; scale with replicas |
| Dashboard (optional) | Deployment + Service + Ingress |
firm-ui; one replica |
| Trim / prune (optional) | CronJobs |
firm-cache trim, firm-channel trim, firm-audit prune |
A worker Deployment is your app's image running a different command — the fields the subsections below explain are the load-bearing ones:
apiVersion: apps/v1
kind: Deployment
metadata: { name: firm-workers }
spec:
replicas: 3 # scale throughput here
template:
spec:
terminationGracePeriodSeconds: 120 # >= your longest job (see below)
containers:
- name: worker
image: your-registry/yourapp:tag # your app image, with firm installed
# exec form -> the supervisor is PID 1 and receives SIGTERM for a graceful drain
command: ["firm-queue", "start", "--mode", "fork",
"--import", "yourapp.jobs", "--queues", "*", "--threads", "5"]
env:
- name: FIRM_QUEUE_DATABASE_URL
valueFrom: { secretKeyRef: { name: firm-db, key: url } }
livenessProbe: # no HTTP port -> probe the process
exec: { command: ["pgrep", "-f", "firm-queue"] }
Graceful shutdown & rolling deploys¶
The supervisor treats SIGTERM/SIGINT as a graceful drain: it stops claiming new work, lets
in-flight jobs finish within shutdown_timeout (default 5s), forwards TERM to its children, and
SIGKILLs any that overrun (see
Signals). Kubernetes sends SIGTERM,
then SIGKILLs after terminationGracePeriodSeconds (default 30s).
- Set
terminationGracePeriodSecondsabove your longest job (and raiseshutdown_timeoutto match) so k8s doesn't hard-kill mid-drain. - Make the supervisor PID 1 so it receives the signal — run the CLI as the container's command
directly (exec form), not wrapped in a shell that swallows
SIGTERM. If you must wrap it, useexec firm-queue ….
Hard kills are safe
Every process heartbeats into firm_queue_processes; when one dies, its in-flight claims are
recovered to firm_queue_ready_executions and another worker finishes them (at-least-once — keep
jobs idempotent). An ungraceful kill is never lost work; graceful shutdown just avoids
re-running in-flight jobs. See crash recovery.
Database connections¶
The default engine pool is pool_size=20 + max_overflow=40 — up to 60 connections per
process — and fork mode runs worker + dispatcher (+ scheduler) as separate processes, each with
its own pool. Multiply by replicas and you can exhaust PostgreSQL's max_connections quickly.
- Right-size the pool to your worker's
threads(a 5-thread worker needs ~5–8 connections, not 60) by passingpool_size/max_overflowtoconfigure()in your worker entrypoint, or front the database with PgBouncer (transaction pooling) and keep app pools small. pool_pre_pingis on for PostgreSQL/MySQL, so dropped connections (failovers, PgBouncer restarts) heal automatically.
Health & liveness¶
Workers expose no HTTP endpoint — if the supervisor exits, the container exits and k8s restarts
it, so a simple process-liveness probe (pgrep -f firm-queue) is enough. For a deeper check,
assert the pod's row in firm_queue_processes has a fresh last_heartbeat_at.
The dashboard does serve HTTP, but with Basic auth every request returns 401, which an
httpGet probe counts as a failure — use a tcpSocket probe on its port instead.
Autoscaling (optional)¶
You often don't need it — idle workers back off their polling, so a right-sized fixed replica count
is a legitimate setup. If you do want it: the built-in CPU HorizontalPodAutoscaler works with
no extra components, and to scale on actual backlog instead, point KEDA's
PostgreSQL scaler (or a Prometheus adapter feeding a standard HPA) at
SELECT count(*) FROM firm_queue_ready_executions. Either way, remember the GIL: scale CPU-bound work
with more replicas (processes), not more --threads.
Recurring tasks¶
Wire recurring tasks into your worker entrypoint and let firm's scheduler run them — it's dedup-safe across replicas, so no singleton deployment is needed:
from firm.queue.scheduler import RecurringTask
from firm.queue.supervisor import SupervisorConfig, WorkerConfig
from myapp.jobs import cleanup
config = SupervisorConfig(
workers=[WorkerConfig()],
recurring=[RecurringTask(key="nightly-cleanup", schedule="0 3 * * *", job=cleanup)],
)
Migrations¶
Run migrations as a one-shot Job (or an init container / pre-upgrade hook) before rolling out
workers. Each module keeps its own version table (firm_<module>_alembic_version), so all four can
share one database and migrate independently — see
Database backends. In a source checkout that's
alembic -c alembic.queue.ini upgrade head. A pip-installed image doesn't ship the .ini, so
create and stamp the baseline from code instead — it's idempotent, so it's safe to run before every
rollout: