queues.md

The jobs, the schedules, retries, and how to look inside

Queues

Everything that takes longer than a request is a job: the monitoring cycle, the fleet scan, a single repository scan, the dependency freshness check and the weekly report.

They used to run inside the API process, detached with a module-level variable and guarded by a 409 that only held for as long as that process lived. A restart lost the work and the progress describing it, and a second API container would have run everything twice. They are pg-boss jobs now, in the same Postgres as everything else — no Redis: the workload is an hourly cycle and a nightly sweep, and a second datastore would be a second place for state to disagree with the database.

The queues

Defined once in src/infrastructure/queue/jobs.js, which the API, the worker and the schedules all read.

Queue Policy Retries What it does
monitor.cycle exclusive 2, after 60s Fetch every enabled feed, filter by stack, dedupe, notify
repo.scanAll exclusive none Walk each organization: import what appeared, remove what is gone, scan each repository
repo.scan standard 2, after 30s One repository's manifests
deps.versions exclusive per repository 1, after 60s Ask each registry for the latest published version
report.weekly exclusive 3, after 5min The Monday digest

exclusive is the old 409. The queue allows one job queued or active at a time, so a second send returns no id — and that is what the API answers 409 to. The difference from the variable it replaced is that this holds after a restart, and across however many API containers are running.

deps.versions is exclusive per repository, through a singletonKey of repo:<id>: two repositories may be checked at once, the same one may not.

repo.scan is deliberately not exclusive — the jobs must be free to queue up. How many run at once is the worker's concurrency, SCAN_CONCURRENCY (default 10), the same number a fleet sweep uses inside its own job: the limit that matters is somebody else's rate limit, and it does not care which queue the work arrived on.

Concurrency. A sweep of four hundred repositories at ten seconds each is over an hour one at a time, so repositories are scanned in parallel — ten by default. Each one is an independent read of the provider followed by a write of its own rows, so they do not contend with each other; what they share is the GitHub token's 5000 requests an hour, which parallelism spends faster rather than differently. Set SCAN_CONCURRENCY, or pass {"concurrency": N} to POST /api/v1/repositories/scan-all for one run (atalaia repo scan --all --concurrency N).

Schedules

Rows in the database, not node-cron calls in every process that boots:

Queue Cron from Default
monitor.cycle the cronSchedule setting 0 * * * *
repo.scanAll the repositories.scanCron setting, and only when repositories.autoScan is on 0 3 * * *
report.weekly WEEKLY_REPORT_CRON 0 9 * * 1

The worker registers them on boot, so changing a schedule in the console takes effect when the worker restarts — the same as it needed before. Turning autoScan off unschedules the sweep rather than skipping it, so an earlier boot's schedule does not linger.

Progress

A pg-boss payload is immutable, so progress lives in its own table, job_progress, keyed by job id and written by the worker as it goes. The API reads it, which a module-level variable never allowed.

The HTTP contract the console polls did not change:

Route Behaviour
POST /api/v1/scan 202 with a jobId, or 409 while one is running
GET /api/v1/scan running, jobId, progress, lastRun
POST /api/v1/repositories/scan-all 202, or 409
GET /api/v1/repositories/scan-all fleet progress: organizations and repositories done, which one is current, errors
DELETE /api/v1/repositories/scan-all Cancel the sweep, and unstick the queue when it thinks one is running
POST /api/v1/repositories/:id/scan 202 for one repository
POST /api/v1/repositories/:id/versions 202, or 409 if that repository is already being checked

Looking inside

./scripts/atalaia.sh logs atalaia-worker

# Queue depths
psql "$DATABASE_URL" -c "
  SELECT name, state, count(*) FROM pgboss.job GROUP BY name, state ORDER BY name;"

# The registered schedules
psql "$DATABASE_URL" -c "SELECT name, cron FROM pgboss.schedule;"

# What failed, and why
psql "$DATABASE_URL" -c "
  SELECT name, created_on, output FROM pgboss.job
  WHERE state = 'failed' ORDER BY created_on DESC LIMIT 5;"

From the terminal client:

atalaia scan                # queue a cycle
atalaia status              # what is stored
atalaia repo scan --all     # queue the fleet sweep
atalaia repo scan-status    # follow it

What happens when a worker dies

The job stays in the queue. It is not lost — which is the whole point of the change — but it is not instantly retried either: pg-boss notices an abandoned active job when its expireInSeconds window passes, and only then hands it to someone else. That window is per queue (15 minutes for a monitoring cycle, an hour for a fleet sweep) and it is a trade: shorter means faster recovery, longer means a slow-but-alive job is not killed and restarted underneath itself.

A clean docker stop is different — the worker finishes the job in hand if it can, and stops taking new ones.

Rebuilding the containers mid-sweep is the case that bites: the worker is replaced, its job stays active, and an exclusive queue refuses new work until the window passes. When that happens:

atalaia repo scan-cancel      # or: DELETE /api/v1/repositories/scan-all

The windows are sized for the work: 15 minutes for a monitoring cycle, 30 for a fleet sweep — which is generous now that a sweep of four hundred repositories is minutes rather than an hour.