Skip to content

Architecture: Jobs, worker & outbox

Why pg-boss, not Redis

docs/PLAN.md's architecture diagram names BullMQ/Redis as the general case, with a Tier-0 alternative: "on Tier-0 hardware, Redis is replaced by the in-process pg-boss driver — same job abstraction, one fewer daemon." What's actually implemented in packages/jobs today is the pg-boss path — PostgreSQL-backed, no separate queue daemon to run alongside the database you already need. createJobQueue() wraps pg-boss and mirrors every job's lifecycle transitions into a jobs ledger table as it goes, so job state is queryable the normal way (the Jobs dashboard reads from this ledger, not from pg-boss's own internal tables directly).

Job types

A closed registry (packages/jobs/src/types.ts) — scan, probe, image, metadata, metadata-search, import, image-backfill, hwprobe, transcode, subtitle-extract, pg-upgrade (metadata-search is the bounded candidate-search job behind the admin "Fix match" flow, distinct from the scan-time metadata job). apps/worker/src/index.ts is the single entry point that registers a consumer for each, via queue.work(...) — that file is the map from job type to handler module (scan/scanner.ts, probe/consumer.ts, metadata/consumer.ts, image/consumer.ts, transcode/..., import/consumer.ts, subtitles/consumer.ts, and so on). CLAUDE.md invariant 6 — nothing spawns a conversion process inline from a request path; it always goes through this queue, handled by the worker process.

The outbox

        a state change happens
        (item added, scan completes,
         playback starts, progress ticks, …)

                    │  same DB transaction

        ┌───────────────────────────┐
        │   events table (the        │
        │   "outbox")                 │
        │   id · type · ts_ms ·       │
        │   actor_user_id · payload   │
        │   (JSONB) · processed_at_ms │
        └─────────────┬─────────────┘

          read side: packages/db/src/query/events.ts's
          readEventsForViewer() — same guard model as
          catalog reads (per-event-type visibility
          checked against live state, not a payload
          snapshot)

           ┌───────────┴────────────┐
           ▼                        ▼
    websocket broadcaster      activity log
    (today's consumers)

    tomorrow: webhooks, sync, plugins — all addable
    without touching the code paths that emit events

Every event that any future feature could plausibly care about (item.added, playback.started, progress.updated, user.created, scan.completed, …) is written as a typed row in the same transaction as the change it describes (docs/PLAN.md §4.3). This is what makes the outbox reliable: there's no window where the state change committed but the event didn't, or vice versa. Event payload schemas live in packages/contract alongside the API and follow the same additive-only evolution policy as the rest of the contract.

See also

  • Libraries & scanning — the admin-facing view of what the scan job actually does.
  • Jobs dashboard — the admin-facing view of the job ledger this page describes from the implementation side.

Released under the AGPL-3.0-only license. No telemetry, ever.