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.

  • Backend & APIs
  • $background-jobs
  • 176 lines
  • 3 files
  • ~1,950 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- background-jobs

Installs to ~/.agents/skills/background-jobs/ for every project on this machine.

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

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.

~/.agents/skills/background-jobs/SKILL.md

--- name: background-jobs description: Designs and fixes background job processing with BullMQ, Sidekiq, Celery and Postgres-backed queues (Solid Queue, River, Oban, pg-boss,… license: MIT ---

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

SituationDefault
Node/TypeScript with Redis availableBullMQ
Ruby on RailsSolid Queue (Rails 8 default) or Sidekiq for high throughput
Python / DjangoCelery with Redis or RabbitMQ; Procrastinate or Dramatiq for simpler needs
GoRiver (Postgres) or Asynq (Redis)
ElixirOban (Postgres)
Need transactional enqueue with the business writeA Postgres-backed queue in the same database
Multi-step, long-running, human-in-the-loop processesNot a job queue: use temporal-workflows
Fan-out events consumed by several servicesA broker: use rabbitmq-messaging or aws-sqs-sns
Run every N minutes / cronScheduler 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.

StackEnqueue after commit
Djangotransaction.on_commit(lambda: send_receipt.delay(invoice.id))
Railsafter_commit callbacks, or Active Job’s after-commit enqueue support in recent Rails
SQLAlchemy / genericcollect jobs during the unit of work, enqueue after commit() returns
Postgres-backed queueinsert 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:

ErrorAction
Network error, timeout, 429, 5xx, deadlock, lock timeoutRetry with exponential backoff and jitter
Validation error, 4xx from a dependency, missing record that will never existFail 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 handlerFail; fix and replay from the dead set
  • Every job has a timeout shorter than the broker’s redelivery window (Celery Redis visibility_timeout, BullMQ lockDuration with 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.yamlgenerated from this page

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.md153 lines, 5.5 KB
# 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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem