Background Jobs skill for Codex
Jobs that survive retries, crashes and deploys. Codex designs jobs with ID-only payloads, enqueues them only after the transaction commits, writes handlers that are safe to run twice, and splits errors into backoff retries and permanent failures. It sizes concurrency to the database pool, separates critical from bulk queues, checks timeouts against redelivery windows, and alerts on oldest-job age rather than queue depth.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- background-jobs Installs to ~/.agents/skills/background-jobs/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install background-jobs The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project background-jobs Installs to .agents/skills/background-jobs/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/background-jobs.zipunzip -o background-jobs.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) background-jobs PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $background-jobs, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs and fixes background job processing with BullMQ, Sidekiq, Celery and Postgres-backed queues (Solid Queue, River, Oban, pg-boss, graphile-worker) - enqueue after commit, small ID-based payloads, idempotent handlers, retry and backoff classification, dead jobs, timeouts, queue separation, graceful shutdown and queue-latency monitoring. Use when moving slow work out of a request, adding a worker, jobs run twice or vanish, retries hammer a dependency, queues back up, or when choosing a job library.
Use it when
- Moving slow or unreliable work out of HTTP requests
- Jobs run twice, disappear, or run before their data exists
- Retries overwhelm a dependency or never stop
- Queues back up and urgent jobs wait behind bulk work
Use something else when
- Long multi-step workflows with timers or human steps: use temporal-workflows
- Recurring scheduled tasks and overlap locks: use cron-scheduling
How to check the result
Each handler run twice with the same payload yields one side effect, a killed worker's job completes once on retry, and a rolled-back transaction enqueues nothing.
The mistake it prevents
Enqueueing a job inside a database transaction, so the worker either runs before the row is visible or processes data that was rolled back.
A job system is healthy when every job can run twice without harm, jobs are enqueued only for data that actually committed, failures are split into “retry later” and “never retry”, and someone is alerted when the oldest waiting job gets too old.
Choose the queue
| Situation | Default |
|---|---|
| Node/TypeScript with Redis available | BullMQ |
| Ruby on Rails | Solid Queue (Rails 8 default) or Sidekiq for high throughput |
| Python / Django | Celery with Redis or RabbitMQ; Procrastinate or Dramatiq for simpler needs |
| Go | River (Postgres) or Asynq (Redis) |
| Elixir | Oban (Postgres) |
| Need transactional enqueue with the business write | A Postgres-backed queue in the same database |
| Multi-step, long-running, human-in-the-loop processes | Not a job queue: use temporal-workflows |
| Fan-out events consumed by several services | A broker: use rabbitmq-messaging or aws-sqs-sns |
| Run every N minutes / cron | Scheduler on top of the queue: use cron-scheduling |
Stay with the library already in the lockfile unless it cannot meet a hard requirement.
Workflow
- [ ] 1. Inventory the queue setup and failure history
- [ ] 2. Design the job contract
- [ ] 3. Enqueue safely
- [ ] 4. Write an idempotent handler
- [ ] 5. Configure retries, timeouts and dead jobs
- [ ] 6. Size concurrency and separate queues
- [ ] 7. Shutdown, deploy and monitor
1. Inventory
Read AGENTS.md, the worker entrypoint, queue config (sidekiq.yml, celery.py,
config/queue.yml, BullMQ new Worker(...) calls), the process manager (Procfile,
Kubernetes Deployment) and recent failed/dead jobs if accessible. Note broker, retry
defaults, concurrency, and how workers are stopped on deploy.
2. Job contract
- Payload holds identifiers and small scalars (
{ "invoiceId": "inv_1" }), never whole ORM objects, file contents or secrets. The handler loads fresh state. - Payload is JSON-native and versioned when its shape changes; old jobs already in the queue must still deserialize after a deploy.
- One job does one unit of work. Large batches fan out into child jobs with a parent that tracks completion.
- Give jobs deterministic IDs when duplicates are possible (
send-receipt-inv_1) and use the library’s dedupe feature.
3. Enqueue safely
Enqueueing inside a database transaction that later rolls back produces jobs for rows that do not exist; enqueueing before commit lets the worker run before the row is visible.
| Stack | Enqueue after commit |
|---|---|
| Django | transaction.on_commit(lambda: send_receipt.delay(invoice.id)) |
| Rails | after_commit callbacks, or Active Job’s after-commit enqueue support in recent Rails |
| SQLAlchemy / generic | collect jobs during the unit of work, enqueue after commit() returns |
| Postgres-backed queue | insert the job in the same transaction (this is the strongest option) |
If the enqueue after commit can fail (Redis down), the event can be lost; for must-not-lose work use a Postgres queue or the outbox pattern (see outbox-pattern).
4. Idempotent handler
Every mainstream queue is at-least-once: crashes, lock expiry and redelivery all cause repeats.
@app.task(bind=True, acks_late=True, autoretry_for=(TransientError,),
retry_backoff=True, retry_backoff_max=600, retry_jitter=True, max_retries=8)
def send_receipt(self, invoice_id: str) -> None:
invoice = Invoice.objects.get(pk=invoice_id)
if invoice.receipt_sent_at: # already done: exit quietly
return
email_provider.send(
to=invoice.customer_email,
template="receipt",
idempotency_key=f"receipt:{invoice_id}", # provider-side dedupe if supported
)
Invoice.objects.filter(pk=invoice_id, receipt_sent_at__isnull=True).update(
receipt_sent_at=timezone.now()
)
Patterns: check-then-act guarded by a conditional update, unique constraints on the effect, upserts, and passing idempotency keys to external APIs.
5. Retries, timeouts, dead jobs
Classify errors explicitly:
| Error | Action |
|---|---|
| Network error, timeout, 429, 5xx, deadlock, lock timeout | Retry with exponential backoff and jitter |
| Validation error, 4xx from a dependency, missing record that will never exist | Fail permanently (BullMQ UnrecoverableError, Celery: do not list it in autoretry_for, Sidekiq: retry: false or rescue and discard) |
| Record not yet visible (replication lag) | A few short retries, then fail |
| Bug in the handler | Fail; fix and replay from the dead set |
- Every job has a timeout shorter than the broker’s redelivery window (Celery Redis
visibility_timeout, BullMQlockDurationwith lock renewal, SQS visibility timeout); otherwise long jobs are redelivered while still running. - Wrap external calls in their own timeouts; a job timeout is the last resort.
- Exhausted retries land in a dead set/failed queue. Alert on it, keep jobs long enough to replay (7-30 days), and add a replay command.
6. Concurrency and queue separation
- Separate queues by latency class (
critical,default,bulk) so a backfill cannot delay password-reset emails. Give critical queues dedicated workers, not just weights. - Concurrency is bounded by the scarcest downstream resource: database connections
(
concurrency x processes <= pool budget), third-party rate limits, memory per job. - Throttle jobs that call rate-limited APIs with a limiter (BullMQ
limiter, Sidekiq Enterprise limiters, a token bucket) instead of letting them fail and retry. - CPU-heavy jobs in Node block the event loop; use sandboxed processors or a separate worker pool.
7. Shutdown, deploys, monitoring
- On SIGTERM, stop fetching, let in-flight jobs finish within a grace period, then exit. Set the orchestrator’s termination grace period longer than the longest normal job.
- Deploy handler changes compatible with jobs already enqueued (accept old and new payloads for one release).
- Monitor per queue: depth, oldest-job age (the best single alert), processing time p95, failure rate, dead-set size. Alert on age, not depth.
Framework-specific configuration (BullMQ, Sidekiq, Celery, Postgres queues) with the settings that most often cause lost or duplicated jobs is in references/frameworks.md; read the section for the library in use before changing its config.
Gotchas
- Passing ORM objects as job arguments serializes stale state (or fails outright).
- Retrying non-idempotent jobs sends duplicate emails and double charges.
- Infinite retries with no dead state hide a broken handler for weeks.
- Scheduled/delayed jobs on a Redis broker with a short visibility timeout can execute multiple times (a known Celery trap).
- A single queue for everything means queue depth alerts fire on backfills and miss real latency problems.
- Killing workers with SIGKILL during deploy loses or repeats in-flight jobs depending on the library’s fetch semantics.
Verification
- Unit test each handler twice with the same payload and assert one side effect.
- Test a transient failure retries and a permanent failure goes straight to dead.
- In an integration environment, kill a worker mid-job and confirm the job is retried and completes exactly once in effect.
- Enqueue inside a rolled-back transaction and confirm no job runs.
- Check dashboards or metrics expose oldest-job age per queue.
Output template
## Job: <name>
- Queue / priority class: <queue>
- Payload: <fields, version>
- Enqueue point: <after commit mechanism>
- Idempotency: <guard>
- Retry policy: <attempts, backoff, permanent errors>
- Timeout: <job> (< redelivery window <value>)
- Concurrency limit: <n, reason>
- Alerts: <oldest age threshold, dead set> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Background Jobs"
short_description: "Jobs that survive retries, crashes and deploys"
default_prompt: "$background-jobs Move PDF generation out of the request into a background job" references/frameworks.md
# Library-specific settings
Read only the section for the library the project uses. Check the installed version in
the lockfile first; option names below match current major versions (BullMQ 5, Sidekiq
7/8, Celery 5.x).
## BullMQ (Node, Redis)
```ts
import { Queue, Worker, UnrecoverableError } from 'bullmq';
import IORedis from 'ioredis';
// Workers use blocking commands: maxRetriesPerRequest must be null.
const connection = new IORedis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });
export const emailQueue = new Queue('email', {
connection,
defaultJobOptions: {
attempts: 8,
backoff: { type: 'exponential', delay: 2_000 },
removeOnComplete: { age: 24 * 3600, count: 10_000 },
removeOnFail: { age: 14 * 24 * 3600 },
},
});
await emailQueue.add('receipt', { invoiceId }, { jobId: `receipt-${invoiceId}` });
const worker = new Worker(
'email',
async (job) => {
const invoice = await loadInvoice(job.data.invoiceId);
if (!invoice) throw new UnrecoverableError('invoice not found');
await sendReceipt(invoice);
},
{ connection, concurrency: 10, limiter: { max: 50, duration: 1_000 } },
);
worker.on('failed', (job, err) => log.warn({ jobId: job?.id, err }, 'job failed'));
for (const sig of ['SIGTERM', 'SIGINT']) {
process.on(sig, async () => { await worker.close(); process.exit(0); });
}
```
Traps:
- A custom `jobId` that already exists is silently ignored while the old job is kept
(that is the dedupe), including completed jobs not yet removed. Use the `deduplication`
option when you need a time-bounded dedupe window instead.
- Custom job IDs must not be plain integers; avoid `:` in them too, since BullMQ uses it
as a Redis key separator.
- `removeOnComplete: true` deletes immediately, which also disables `jobId` dedupe for
repeats; keep a count or age window.
- Long CPU-bound processors block lock renewal and get marked stalled, then run twice.
Use a sandboxed processor file or keep the event loop free.
- Use a separate Redis (or at least `maxmemory-policy noeviction`) for queues; eviction
silently deletes jobs.
- Repeatable/cron jobs: use job schedulers (`upsertJobScheduler`) in current BullMQ
versions rather than the older repeat API.
## Sidekiq (Ruby, Redis)
```ruby
class SendReceiptJob
include Sidekiq::Job
sidekiq_options queue: "critical", retry: 8
sidekiq_retry_in do |count, exception|
case exception
when PermanentError then :kill # straight to the Dead set
else (count ** 4) + 15 + rand(10 * (count + 1))
end
end
sidekiq_retries_exhausted do |job, ex|
Rails.logger.error("receipt job exhausted #{job['jid']}: #{ex.message}")
end
def perform(invoice_id)
invoice = Invoice.find_by(id: invoice_id)
return if invoice.nil? || invoice.receipt_sent_at?
ReceiptMailer.with(invoice:).receipt.deliver_now
invoice.update!(receipt_sent_at: Time.current)
end
end
SendReceiptJob.perform_async(invoice.id) # JSON-native args only
```
```yaml
# config/sidekiq.yml
:concurrency: 10
:timeout: 25
:queues:
- [critical, 6]
- [default, 3]
- [bulk, 1]
```
Traps:
- Default retry count is 25 (about three weeks of retries); set it per job.
- Sidekiq 7+ rejects non-JSON-native arguments (symbols, objects) by default; pass IDs.
- Open-source Sidekiq fetch removes the job from Redis when a worker picks it up; a
hard kill (OOM, SIGKILL) loses in-flight jobs. Graceful TERM pushes unfinished jobs
back after `:timeout`. Reliable fetch is a Sidekiq Pro feature.
- `concurrency` threads each need a database connection: size the Active Record pool
at least to concurrency.
- Weighted queues still starve under load; run a dedicated process for `critical`.
## Celery (Python)
```python
# celery.py
app.conf.update(
task_acks_late=True, # ack after the task finishes
task_reject_on_worker_lost=True, # requeue if the worker process dies
worker_prefetch_multiplier=1, # do not hoard tasks on one worker
task_time_limit=300, # hard kill
task_soft_time_limit=240, # raises SoftTimeLimitExceeded first
broker_transport_options={"visibility_timeout": 3600}, # Redis/SQS: > longest task + ETA
task_routes={"billing.*": {"queue": "critical"}, "reports.*": {"queue": "bulk"}},
)
```
```bash
celery -A proj worker -Q critical -c 8 --max-tasks-per-child 500
celery -A proj worker -Q default,bulk -c 4
```
Traps:
- With `acks_late`, tasks must be idempotent; that is the point of the setting.
- On Redis or SQS brokers, tasks with ETA/countdown longer than `visibility_timeout`
are redelivered and run more than once. Keep long delays in a scheduler instead.
- `retry_backoff=True` without `retry_jitter=True` synchronises retries across workers.
- Passing model instances pickles stale data; use the JSON serializer and pass IDs.
- `--max-tasks-per-child` mitigates memory leaks in long-lived worker processes.
## Postgres-backed queues
Solid Queue (Rails), River (Go), Oban (Elixir), pg-boss and graphile-worker (Node),
Procrastinate (Python) store jobs in Postgres and claim them with
`FOR UPDATE SKIP LOCKED`.
- Big advantage: enqueue in the same transaction as the business write, so a job exists
if and only if the data committed.
- Throughput is lower than Redis queues (low thousands of jobs per second is typical,
depending on hardware); fine for most product workloads.
- Completed jobs bloat tables; configure the library's pruning and watch autovacuum on
the jobs table.
- Use a separate connection pool or database for job polling if the primary pool is
tight.
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.