Operations

Stafett keeps no state of its own beyond pgqueuer's tables in schema stafett. Inspecting, replaying, and cleaning up jobs uses pgqueuer's pgq command, which ships in both Stafett images.

Running pgq

pgq reads its connection and schema from the environment. Inside a Stafett container:

kubectl -n stafett exec deploy/stafett-worker-deliver -- sh -c \
  'PGDSN=$DATABASE_URL PGQUEUER_SCHEMA=stafett PGQUEUER_CHANNEL=stafett_ch_pgqueuer pgq <command>'

With Docker Compose, swap the first line for docker compose exec deliver sh -c. The examples below show only pgq <command>.

Always set PGQUEUER_SCHEMA=stafett. Without it pgq uses the connection's search_path, usually schema public, which is empty or holds a different pgqueuer install.

Common tasks

Task Command
List dead jobs (25 by default; -n for more) pgq failed
The same, as JSON pgq failed --json
Why a job failed the SQL below
Replay dead jobs pgq requeue <id> [<id> …]
How many jobs wait, and how old the oldest is pgq backlog
Jobs stuck in delivering pgq stale
Worker processes holding jobs pgq workers
Live terminal dashboard pgq dashboard
Status of one job GET /jobs/{id} on enqueue
Cancel one job DELETE /jobs/{id} on enqueue

Run pgq <command> --help for the options.

pgq failed shows pgqueuer's own attempt counter, which counts retries in the current cycle and resets on replay. It reads one lower than the attempts from GET /jobs/{id}, which counts every attempt over the job's life.

Each failed attempt records its reason, such as HTTP 404 or connect error, in pgqueuer's log. pgq failed does not show it; query it:

SELECT created, status, traceback->>'exception_message' AS reason
FROM stafett.pgqueuer_log
WHERE job_id = 1234 AND traceback IS NOT NULL
ORDER BY id;

pgq web, pgqueuer's browser dashboard, is not in the image: it needs pgqueuer's web extra. If you install it elsewhere, set both PGQUEUER_WEB_USER and PGQUEUER_WEB_PASSWORD, because it shows every job's URL, headers, and payload, and keep it internal.

Replay

  [ dead ] ── pgq requeue <id> ──► [ retrying ] ──► deliver picks it up
                                                      within 5 seconds

A replay gives the job a fresh retry budget. Until its next attempt, GET /jobs/{id} reports it as retrying, because it has attempts behind it. The receiver sees the same webhook-id as before, and the attempt count keeps climbing across replays.

A replay can fail with a unique-key error if the job had a Stafett-Dedupe-Key and a newer job with the same key is queued or in flight.

Fix the cause first: replaying into a destination that still answers 404 kills the job again on its first attempt.

What to watch

Signal Where Worry when
Backlog size and age pgq backlog It grows for longer than your destinations' slowest normal period.
Dead jobs pgq failed Any new ones: each is a delivery that did not happen.
Stale jobs pgq stale Any. A live deliver pod re-picks a job 30 s after its pod stopped sending heartbeats, and the destination may see a repeat. Jobs stale for 5 minutes (the default threshold) mean no deliver pod is picking them up.
Delivery liveness /healthz on deliver, pod restarts Restarts repeat: check the deliver log and the database connection.
Table health pg_stat_user_tables for stafett.pgqueuer n_dead_tup stays high: autovacuum is not keeping up.

Stafett logs to stderr. It never logs payloads, destination URLs, headers, or the signing secret. A deliver pod whose delivery loop stopped logs the error only when the process exits.

Postgres housekeeping

The queue table churns: every delivered job is a deleted row. pgqueuer applies autovacuum settings tuned for its tables:

pgq autovac              # apply them to schema stafett
pgq autovac --rollback   # back to Postgres defaults

Two tables grow without bound, and Stafett never deletes from them:

To bound them, delete old rows yourself, for example older than 30 days, from a scheduled job such as a Kubernetes CronJob or pg_cron:

DELETE FROM stafett.pgqueuer_log WHERE created < now() - INTERVAL '30 days';
DELETE FROM stafett.pgqueuer_statistics WHERE created < now() - INTERVAL '30 days';

After that, GET /jobs/{id} answers 404 for jobs that finished before the cut-off.

Signing secret rotation

There is one signing secret per install, and no overlap window yet. Rotating it means:

  1. Update STAFETT_SIGNING_SECRET in the Secret.
  2. Restart deliver (kubectl rollout restart deploy/stafett-worker-deliver).
  3. Update every receiver.

Deliveries between steps 2 and 3 fail verification at the receiver. If the receiver answers 4xx, those jobs are dead; replay them once the receivers have the new secret.

Upgrades

New pods run setup before they start, which upgrades the schema in place. Jobs written by a newer version that an older deliver does not understand are dead with reason unknown envelope version; replay them once every deliver pod is upgraded.