Running workers
Start the supervisor, split roles across containers, and keep workers from leaking with recycling.
The CLI#
pgbus start # supervisor: workers + dispatcher + scheduler + consumers
pgbus status # show running processes
pgbus queues # list queues with depth/metrics
pgbus version # print the versionSplit-role deployments#
One role per container.
By default pgbus start boots every role in one supervisor. For containerized deployments where each role is its own process, use the role flags (mutually exclusive) and, optionally, a single capsule:
pgbus start --workers-only # only worker processes
pgbus start --scheduler-only # only the recurring-task scheduler
pgbus start --dispatcher-only # only the maintenance dispatcher
pgbus start --workers-only --capsule critical # one capsule per containerpool_size follows the role: a --scheduler-only process opens only the connections it actually needs, not one per configured worker thread.Worker recycling#
The fix for the memory-bloat problem.
pgbus workers retire themselves before they leak — the main reliability difference from backends that leave workers alive forever. When a limit is hit, the worker drains its thread pool, exits, and the supervisor forks a fresh process:
Pgbus.configure do |config|
config.max_jobs_per_worker = 10_000 # restart after 10k jobs
config.max_memory_mb = 512 # restart above 512 MB RSS
config.max_worker_lifetime = 1.hour # restart after an hour
endRSS is sampled from /proc/self/statm on Linux and ps -o rss on macOS.
A capsule can set its own limits. They apply to that capsule's workers only; any limit it leaves unset (or nil) falls back to the global value:
Pgbus.configure do |config|
config.max_memory_mb = 512 # every other capsule
config.capsule :render, queues: %w[render], threads: 1, processes: 4,
max_memory_mb: 1_536, max_worker_lifetime: 6.hours
endThreads vs processes#
For CPU-bound jobs.
Ruby threads in one process share the GVL. Threads help when jobs wait on I/O; they do not help when jobs burn CPU (rendering, reports, PDFs, images), where a capsule with threads: 8 still uses about one core. processes: N forks N identical workers for one capsule, each with its own GVL:
Pgbus.configure do |config|
config.capsule :render, queues: %w[render], threads: 1, processes: 4
end- I/O-bound jobs:
threads:(orexecution_mode: :async),processes: 1. - CPU-bound jobs:
processes:up to the cores you can give the capsule,threads: 1–2.
All forks read the same queues with FOR UPDATE SKIP LOCKED, so uniqueness and concurrency controls behave as with one process. Each fork has its own restart backoff, liveness pipe and pgbus_processes row; a crashed fork restarts alone. The boot banner prints processes=N, readiness counts every fork, and the Processes page labels each row capsule render · process 2/4.
Budget boot RSS + peak job RSS per process; the forks share the booted app copy-on-write until they write to it. Every process has its own pool, so a host holds about processes × pool_size connections for the capsule. Under the default worker_notify_scope: :supervisor the host still holds one LISTEN connection; under :fork each fork holds its own. With single_active_consumer: true each queue is locked by one fork at a time; a fork can still process the capsule's other queues, so in a single-queue capsule the extra forks are hot standbys.
When to reach for which, with worked examples: Sizing capsules.
Circuit breaker#
Auto-pause a failing queue.
A queue that fails repeatedly is auto-paused with exponential backoff, so a broken dependency doesn't burn the whole fleet retrying. It auto-resumes after the backoff and resets; continued failures double the backoff:
Pgbus.configure { |c| c.circuit_breaker_enabled = true } # defaultpgbus_queue_states and survives restarts; you can also pause/resume manually from the dashboard. Add the table with rails generate pgbus:add_queue_states.Client-level circuit breaker (database-down)#
A different breaker from the one above — trips on connection failure, not job failure.
The circuit breaker above (Pgbus::CircuitBreaker) is per-queue and persists its pause state in the database — so it's useless when the database itself is down; its check_paused rescues and returns false, tripping nothing. Pgbus::Client::ConnectionHealth is a separate, in-memory, process-local latch owned by Pgbus::Client for exactly that case: it trips on repeated connection failures, not job failures, and needs no database access to operate (it can't — the database is down).
| [:code, "Pgbus::CircuitBreaker"] | [:code, "Client::ConnectionHealth"] | |
|---|---|---|
| Scope | Per queue | Per client (whole process) |
| Trips on | Job execution failures | Consecutive PGMQ::Errors::ConnectionError |
| State lives in | pgbus_queue_states (DB) | In-process memory (Mutex-guarded) |
| Survives restart | Yes | No — resets on process start |
| Purpose | Isolate a queue whose job code keeps failing | Stop hammering a database that is down |
ConnectionHealth trips open after 5 consecutive connection errors across any operation. Once open, read paths (read_message, read_batch, read_multi, read_grouped*, read_with_poll) fail fast with Pgbus::ConnectionCircuitOpenError without checking out a pool connection — no wasted connection attempt, no ErrorReporter noise per poll. A single half-open probe is admitted after a monotonic backoff (1s base, doubling per re-open, capped at 60s); its success closes the breaker, its failure re-opens it with a doubled window. Enqueues (send_message / send_batch) are never short-circuited — callers must see enqueue failures rather than have them silently swallowed.
warn when the breaker opens, an info when it closes. There is no configuration for this breaker — the thresholds are constants, mirroring Pgbus::CircuitBreaker.Read timeouts (libpq-native)#
Bounded reads without Ruby Timeout.
config.read_timeout (default 30 seconds) caps how long a single PGMQ read can block. On a dedicated connection (database_url or connection_params), pgbus bakes two libpq-native bounds into the connection at boot — no Ruby Timeout.timeout, which can interrupt mid-libpq-call and leave a pooled connection corrupted for the next checkout:
| Bound | How | Effect |
|---|---|---|
| Server-side | statement_timeout (via options=-c statement_timeout=<ms>) | Postgres cleanly cancels an overrunning query → Pgbus::ReadTimeoutError |
| Client-side | tcp_user_timeout + keepalives (sized read_timeout + 5s) | A dead/hung socket raises `PG::ConnectionBad` synchronously |
The client-side bound only applies on Linux with libpq ≥ 12 (older libpq rejects the tcp_user_timeout conninfo keyword; non-Linux hosts no-op it), detected automatically at connection init — no configuration needed. Ruby Timeout remains only as a narrow last resort on a dedicated connection where libpq can't bound the socket (non-Linux or libpq < 12).
Proc-based shared-AR connection path (`-> { ActiveRecord::Base.connection.raw_connection }`) gets neither bound automatically — pgbus doesn't own that socket. Configure the same libpq timeouts yourself in database.yml:production:
primary:
<<: *default
variables:
statement_timeout: 30000 # ms — match config.read_timeout
# tcp_user_timeout / keepalives: set at the connection-string or
# OS/driver level; ActiveRecord passes libpq options straight through.Prefetch flow control#
Cap the number of in-flight (claimed but unfinished) messages per worker to keep a burst from overwhelming a slow downstream:
Pgbus.configure { |c| c.prefetch_limit = 20 } # nil = unlimited (default)Read-ahead#
For a database on another host.
By default a worker reads as many messages as it has free threads. Once the pool is busy, threads free one at a time, so the worker makes one read round trip per job. Next to the database that is free. Across a network, that round trip becomes the ceiling. read_ahead claims up to N messages beyond the free threads and holds them until a thread frees, so one read feeds many jobs:
Pgbus.configure do |config|
config.read_ahead = 12 # 0 = off (default)
config.capsule :api, queues: %w[api], threads: 12, read_ahead: 12
config.event_consumers = [{ topics: ["orders.#"], threads: 8, read_ahead: 8 }]
endStart with read_ahead equal to threads. Buffered messages stay invisible (the visibility heartbeat holds them) until their job starts. On drain, recycle, pause or shutdown they go straight back to the queue, with read_ct already counted. They also count as in flight, so prefetch_limit still caps the total. The hold needs visibility_heartbeat (on by default); with it off, a message buffered past visibility_timeout can run twice.
Async execution mode (fibers)#
For I/O-bound work.
Workers can run jobs as fibers instead of threads — ideal for I/O-bound workloads (HTTP calls, email, LLM APIs) where jobs spend their time waiting. Because fibers yield during I/O, many share a handful of connections:
Pgbus.configure do |config|
config.execution_mode = :async # all workers, or per-capsule:
config.workers = [
{ queues: %w[webhooks emails], threads: 100, execution_mode: :async },
{ queues: %w[default], threads: 5 } # stays thread-based
]
endgem "async" and config.active_support.isolation_level = :fiber. Don't use it for CPU-bound jobs — they block the reactor. Messages stay protected by the visibility timeout regardless of mode.