$npx -y skills add marckohlbrugge/37signals-skills --skill rails-jobsApply best practices for Rails background jobs using simple orchestration, idempotency, and safe retries. Use when creating, refactoring, or debugging Active Job and queue-backed workflows.
| 1 | # Rails Jobs |
| 2 | |
| 3 | Use for background job design and review. Patterns from Campfire (Resque, 4 thin jobs) and Fizzy (Solid Queue, multi-queue). |
| 4 | |
| 5 | ## Defaults |
| 6 | |
| 7 | - Keep jobs shallow; the job body is one line calling a model method: `def perform(card) = card.notify_recipients`. |
| 8 | - Make jobs idempotent and safe to retry (e.g. rescue `RecordNotUnique` in the domain op). |
| 9 | - Fail loudly on real errors; avoid silent rescue patterns. |
| 10 | - Set `ActiveJob::Base.enqueue_after_transaction_commit = true` in app defaults — fixes job-before-data races at the root. |
| 11 | - Prefer Solid Queue (database-backed) over Redis-backed queues for new apps; co-locate with Puma (`SOLID_QUEUE_IN_PUMA`) in small deployments. |
| 12 | |
| 13 | ## Naming Convention |
| 14 | |
| 15 | - `_later` enqueues, plain name does the work, `_now` when you need an explicit synchronous twin: |
| 16 | |
| 17 | ```ruby |
| 18 | def notify_recipients # the work — public API the job calls |
| 19 | def notify_recipients_later # enqueues NotifyRecipientsJob (often private, called from callbacks) |
| 20 | ``` |
| 21 | |
| 22 | - Capture enqueue-time context as keyword args (`Mention::CreateJob.perform_later(self, mentioner: Current.user)`) — don't rely on `Current` at perform time. |
| 23 | - In multi-tenant apps, serialize tenant context into every job: prepend an ApplicationJob extension that captures `Current.account` at enqueue (as a GlobalID) and wraps `perform_now` in `Current.with_account`. |
| 24 | |
| 25 | ## Good Patterns |
| 26 | |
| 27 | - Small job payloads; pass records (GlobalID) or IDs, never big object graphs. |
| 28 | - Queues split by criticality (`default`, `backend`, `webhooks`) with explicit priority order in queue config. |
| 29 | - Separate orchestration jobs from heavy processing jobs. |
| 30 | - Stagger recurring jobs at odd minutes (12, 27, 50) to avoid synchronized load spikes. |
| 31 | - Bulk enqueue: `due.in_batches { |batch| ActiveJob.perform_all_later(batch.map { DeliverJob.new(it) }) }`. |
| 32 | - Crash-safe long iteration with `ActiveJob::Continuable`: `step :dispatch` + `find_each(start: step.cursor)` + `step.advance!` — essential for fan-out (webhooks, broadcasts, backfills). |
| 33 | - Serialize per-owner work with `limits_concurrency to: 1, key: ->(owner) { owner }` (Solid Queue). |
| 34 | - Two-tier async for high fan-out HTTP (web push): one job for the domain event, then an in-process thread pool for the HTTP calls. Resolve all AR data before posting to the pool; drop on `RejectedExecutionError` for backpressure. |
| 35 | - For `after_destroy_commit` async work, snapshot needed associations in `before_destroy` — the parent rows may be gone when the job runs. |
| 36 | |
| 37 | ## Recurring & Maintenance |
| 38 | |
| 39 | - Recurring tasks invoke plain model class methods (`command: "MagicLink.cleanup"`), not dedicated job classes. |
| 40 | - Retention sweeps use `delete_all` on scopes (`stale.delete_all`) — skip callbacks on stale rows. |
| 41 | - Schedule cleanup for finished queue jobs, expired tokens, old delivery records. |
| 42 | - Prefer reset-on-use over cron resets: check-and-reset inside the domain method (`spend` calls `reset_if_due`), not a scheduled job. |
| 43 | - Trim recurring schedules in beta/staging environments to essentials. |
| 44 | |
| 45 | ## Error Handling Policy |
| 46 | |
| 47 | - Retry transient failures with `retry_on ..., wait: :polynomially_longer` (timeouts, DNS, `Net::SMTPServerBusy`). |
| 48 | - Don't retry permanent failures: rescue, classify by error class/message, log at `:info` severity (it's expected — bad address, full mailbox), and move on. Keep job queue resources for work that can succeed. |
| 49 | - Distinguish "destination failed" (record outcome, complete the job) from "our code raised" (mark errored, re-raise for retry) — see rails-webhooks. |
| 50 | - Package error taxonomies as concerns and include them into framework jobs (`ActionMailer::MailDeliveryJob.include SmtpDeliveryErrorHandling`). |
| 51 | |
| 52 | ## Testing |
| 53 | |
| 54 | - Don't unit-test trivial job classes; test async behavior from model tests with `perform_enqueued_jobs(only: Mention::CreateJob) { ... }` and `assert_enqueued_with(job: ...)`. |
| 55 | - Assert the observable outcome (mention created, email sent), not job internals. |
| 56 | |
| 57 | ## Red Flags |
| 58 | |
| 59 | - Jobs with complex branching and business rules embedded directly. |
| 60 | - Non-idempotent side effects without guards. |
| 61 | - Retrying permanently invalid inputs. |
| 62 | - Enqueueing jobs before required records are committed. |
| 63 | - Reading `Current.user`/`Current.account` inside `perform` without serializing it at enqueue. |
| 64 | - Per-item `perform_later` in a loop where `perform_all_later` or Continuable iteration fits. |
| 65 | - Cron-style reset jobs when reset-on-use logic is simpler and safer. |
| 66 | - Background jobs triggering spurious Turbo broadcasts (wrap in `suppressing_turbo_broadcasts`). |