Imported from zoolutions/pgbus (
AGENTS.md). Install upstream withnpx skills add zoolutions/pgbus. Copyright stays with the author.
Pgbus
PostgreSQL-native job processing and event bus for Rails, built on PGMQ.
Project instructions for every agent: Claude Code (CLAUDE.md imports this file); Grok, Cursor,
Copilot and Codex read this file directly. Claude-only material (slash commands, rules) lives
under .claude/.
Tech Stack
- Ruby: >= 3.3 | Rails: >= 7.1
- Transport: pgmq-ruby (PGMQ — extension or embedded SQL)
- Concurrency: concurrent-ruby
- Autoloading: zeitwerk
- Testing: RSpec
- Linting: RuboCop
Critical Rules
Never Do
- NO direct PGMQ calls — always go through
Pgbus::Client - NO hardcoded queue names — use
config.queue_name() - NO raw SQL in dashboard — use
Web::DataSource - NO
Marshal.load— JSON serialization only - NO unsynchronized shared state — use Mutex or Concurrent primitives
- NO swallowing errors — log via
Pgbus.logger, track inpgbus_failed_events - NO
Recordsuffix on model classes — see Model Naming below
Always Do
- TDD: Write tests BEFORE implementation
- Worker recycling: Configure
max_jobs,max_memory_mb,max_lifetime - Dead letter routing: Check
read_ct>max_retries - LISTEN/NOTIFY: Use
enable_notify_insertfor instant wake-up - Queue prefix: All queues through
config.queue_name() - Visibility timeout: Always pass
vt:parameter on reads - Performance: Measure before/after for hot-path changes (
/perf); seedocs/performance.md
Commands
bundle exec rspec # Run tests
bundle exec rubocop # Lint
bundle exec rake # Both
bundle exec rake bench # Unit benchmarks (serialization, client, executor)
bundle exec rake bench:one[client_bench] # Single benchmark by name
bundle exec rake bench:memory # Detailed memory profiling
bundle exec rake bench:integration # Real DB benchmarks (requires PGBUS_DATABASE_URL)
bundle exec rake bench:streams # SSE streaming benchmarks (requires PGBUS_DATABASE_URL)
bundle exec rake frontend:css # Rebuild app/frontend/pgbus/style.css after adding a Tailwind class to a view
bin/release list # Last releases + next patch/minor/major version
bin/release [minor|major|X.Y.Z] [-n] # Cut a release (patch by default) via rake release (rakelib/release.rake, shared across the zoolutions gems); -n = dry run
Command output is condensed by rtk (PreToolUse hook). .rtk/filters.toml covers this repo's
scripts (rake build); every edit to it needs rtk trust --yes + rtk verify. Write commands in
hook-rewritable shapes: no for/subshell wrappers, no | head on rtk-handled commands,
bundle exec rubocop not bin/rubocop.
Slash Commands
| Command | Purpose |
|---|---|
/lfg |
Full autonomous workflow: branch → understand → explore → plan → TDD → verify → PR |
/plan |
Fable-powered planning → GitHub issue or docs/plans/ markdown (read-only; execute with /lfg) |
/github-review-comments |
Process unresolved PR review comments |
/review-pr |
Review a PR for pattern compliance |
/tdd |
Enforce RED → GREEN → REFACTOR cycle |
/security |
Security audit (PGMQ ops, connections, auth, deserialization) |
/architect |
Coordinate multi-layer development |
/perf |
Benchmark current branch against main (before/after with worktree) |
Models. Sessions run on opus (Opus 5.5) with fable (Fable 5.1) as the advisor (.claude/settings.json). Fable is spent where judgment matters most: /plan runs on Fable, the advisor is consulted at decision points (before choosing an approach, a schema or public API, a migration, a dependency, anything irreversible, and when a failure repeats), and the fable-validator agent checks every finished implementation before its pull request opens (/lfg, Phase 6.5). Commands pin their tier by alias, never by full model ID: opus for orchestration, security, full PR review, payments and production debugging; sonnet for the implementation specialists and TDD; haiku for mechanical scans. Every spawned agent names its model:; one that does not runs on sonnet (CLAUDE_CODE_SUBAGENT_MODEL), never on the session's model. Plan mode cannot take a model of its own: it runs on Opus and asks the advisor.
Architecture
Layer 6: Dashboard app/controllers/pgbus/, app/views/pgbus/
Layer 5: CLI lib/pgbus/cli.rb
Layer 4: Process Model lib/pgbus/process/ (supervisor, worker, dispatcher, consumer)
Execution Pools lib/pgbus/execution_pools/ (thread_pool, async_pool)
Layer 3: Event Bus lib/pgbus/event_bus/ (publisher, subscriber, registry, handler)
Layer 2: ActiveJob lib/pgbus/active_job/ (adapter, executor)
Layer 1: Client lib/pgbus/client.rb (PGMQ wrapper)
Layer 0: Config lib/pgbus/configuration.rb, config_loader.rb
Model Naming
ActiveRecord models live in app/models/pgbus/ and inherit from Pgbus::ApplicationRecord.
Never use a Record suffix. Resolve naming conflicts as follows:
| Model Class | Table | Why not the obvious name |
|---|---|---|
Pgbus::BusRecord |
(abstract) | Base class in lib/pgbus/ — loaded by Zeitwerk gem loader, avoids engine boot-order issues |
Pgbus::ApplicationRecord |
(abstract) | Backward-compatible alias for BusRecord in app/models/ |
Pgbus::BatchEntry |
pgbus_batches |
Pgbus::Batch is the batch API class |
Pgbus::BlockedExecution |
pgbus_blocked_executions |
— |
Pgbus::ProcessEntry |
pgbus_processes |
Process conflicts with Ruby's Process module |
Pgbus::ProcessedEvent |
pgbus_processed_events |
— |
Pgbus::RecurringExecution |
pgbus_recurring_executions |
— |
Pgbus::RecurringTask |
pgbus_recurring_tasks |
Pgbus::Recurring::Task is a different namespace |
Pgbus::Semaphore |
pgbus_semaphores |
Pgbus::Concurrency::Semaphore is a different namespace |
When a model name collides with a service/module name, prefer Entry suffix or a descriptive alternative over Record.
Separate Database Support
Pgbus supports running in the primary database or a dedicated database (like SolidQueue).
Configuration (config.connects_to):
nil(default) — uses the primary Rails database{ database: { writing: :pgbus } }— uses a separate database
Generator flags:
rails generate pgbus:install --database=pgbus— migrations go todb/pgbus_migrate/rails generate pgbus:add_recurring --database=pgbus— recurring migrations also go todb/pgbus_migrate/rails generate pgbus:upgrade_pgmq --database=pgbus— upgrade migrations also go todb/pgbus_migrate/rails generate pgbus:tune_fillfactor --database=pgbus— fillfactor tuning for existing installations- Without
--database— migrations go todb/migrate/(default)
database.yml example:
production:
primary:
<<: *default
database: myapp_production
pgbus:
<<: *default
database: myapp_pgbus_production
migrations_paths: db/pgbus_migrate
Key Design Decisions
- Worker recycling via
max_jobs_per_worker,max_memory_mb,max_worker_lifetime— fixes solid_queue's memory leak problem - LISTEN/NOTIFY via PGMQ's
enable_notify_insertfor instant wake-up (polling as fallback only) - Dead letter queues: after
max_retriesfailed reads (tracked by PGMQ'sread_ct), move to_dlqqueue - Idempotent events:
pgbus_processed_eventstable with (event_id, handler_class) unique index - Dashboard via Tailwind CDN + Turbo CDN — zero npm dependency
- PGMQ schema install: extension-first with embedded SQL fallback (
pgmq_schema_mode: :auto | :extension | :embedded) - Fillfactor=70 on queue tables: reserves 30% page space to reduce page density during PGMQ's heavy read UPDATE churn
- Proactive table maintenance: dispatcher periodically checks pg_stat_user_tables for bloated tables and vacuums them (inspired by pgque)
PGMQ Schema Management
PGMQ can be installed via PostgreSQL extension or embedded SQL (no extension required).
Configuration (config.pgmq_schema_mode):
:auto(default) — tries extension, falls back to embedded SQL:extension— requires the pgmq PostgreSQL extension:embedded— uses vendored SQL, no extension needed
Generators:
rails generate pgbus:install --pgmq-schema-mode=auto— initial setuprails generate pgbus:upgrade_pgmq— upgrade PGMQ schema to latest vendored version
Rake tasks:
rake pgbus:pgmq:status— show installed vs available PGMQ versionrake pgbus:pgmq:versions— list vendored PGMQ versions
Key files: lib/pgbus/pgmq_schema.rb, lib/pgbus/pgmq_schema/pgmq_v*.sql
Queue Naming
All PGMQ queues are prefixed: {queue_prefix}_{name} (default: pgbus_default).
DLQ queues append _dlq suffix.
Labels
Every pull request carries exactly one type label and at least one area
label from .github/labels.yml — never a status label. /plan labels the
issue, /lfg copies the issue's type and area labels onto the PR (never
plan or another status label). Without an issue, the type comes from the
change's conventional-commit prefix and the areas from
bin/labels infer $(git diff --name-only origin/main...HEAD). Labels change in
the manifest and reach GitHub with bin/labels sync, never through the UI.
Rules: .github/LABELS.md. bin/labels + .github/LABELS.md are the shared
labels kit (canonical copy in docs-kit): never edit them in place.
Screenshots on PRs and issues (always)
The dashboard (app/controllers/pgbus/, app/views/pgbus/*.html.erb, app/frontend/pgbus/) is
a real UI — Tailwind + Turbo + ApexCharts. Any change to a dashboard view, its CSS, or its JS
ships with before/after pictures on the PR, attached from the terminal. Never a local path, a
base64 blob, or "screenshot available on request".
gh pr create --attach './after.png#Queue detail page with retry counts' --title … --body … # picture in hand already
gh pr comment <n> --attach './after.png#Queue detail page with retry counts' --body 'Before/after for the queue detail page.'
gh pr comment <n> --attach ./before.png --attach ./after.png # repeat the flag, up to 50 files
gh issue comment <n> --attach ./repro.mp4 # video renders as a player
- Quote the whole argument: the alt text has spaces and bare
</>would redirect.<file>#<alt text>sets the alt text; without it the filename is used. A body that already references the file () gets that reference rewritten to the uploaded asset, so images can sit inline; unreferenced attachments are appended at the end. create,editandcommentall take--attach(all three landed in gh 2.99). Attach at create time when the picture already exists; comment when it comes later, as it does after a verification run againstrake dummy:server.- Capture with the tool already in hand:
agent-browser screenshot <file>or the Playwright MCPbrowser_take_screenshot. Save under the scratchpad, never in the repo. - No
--attachflag means an oldgh:brew upgrade gh.
More Documentation
See .claude/ directory:
commands/— Slash command definitions (including/perffor benchmarking)rules/— Coding style, git workflow, testing, agents, performance, security
See docs/ directory:
docs/performance.md— Hot paths, measuring guide, allocation budgets, CI integration
