ADR-0031 — Job Queue: BullMQ + Self-Hosted Redis
Status: Accepted Date: 2026-08-09
Context
Phase 3.2 needs apps/worker to run scryfall-sync: download Scryfall's default_cards bulk dump once a day and upsert it into Card / CardPrint / CardFace (ADR-0030 settled that the worker writes via Prisma directly). This raises two infrastructure questions the roadmap flags as "significant new dependencies" (CLAUDE.md → ADR required):
- How is background work scheduled and executed? A single daily job today; user-triggered, concurrent, retryable jobs later (Phase 9 PDF generation).
- Where does the backing store (Redis) run, in dev and in prod?
A job queue decouples what needs doing from who does it: a producer enqueues work, one or more workers pull and execute it, with retries, concurrency, and scheduling handled by the queue. BullMQ is the Node implementation of that pattern; it stores queue state in Redis (an in-memory, fast, shared data store).
Decision
1. Adopt BullMQ + Redis now, not later
apps/worker uses BullMQ for all background jobs, backed by Redis. scryfall-sync is registered as a repeatable job on a daily cron schedule.
This is deliberately introduced on a simple, low-stakes job (one daily sync, no user in the loop) even though a bare cron would technically suffice for it. Rationale below.
2. Incremental sync state lives in a SyncState Postgres table
Before downloading anything, scryfall-sync calls getBulkDataInfo() (cheap metadata) and compares the dump's updatedAt against the last dump it successfully processed. Equal → stop (no 2 GB download); newer → fetch + stream + upsert. That "last processed" bookkeeping is persisted in a small Postgres table, keyed by sync source so future syncs (prices, rulings) reuse it:
model SyncState {
source String @id // "default_cards", later "prices"…
status String? // "success" | "running" | "skipped" | "failed"
lastDumpUpdatedAt DateTime? @map("last_dump_updated_at") // Scryfall updatedAt of the last processed dump
lastSyncedAt DateTime? @map("last_synced_at") // when a run last succeeded
lastCardCount Int? @map("last_card_count") // only set on a run that upserted
lastError String? @map("last_error") // failure message when status = "failed"
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("sync_state")
}All tracking fields are nullable: on the very first run there is no history (lastDumpUpdatedAt is null → treat the dump as new), and not every field is meaningful in every state (a skipped run sets no lastCardCount; a failed run sets lastError, not lastCardCount). status gives the context to interpret the nullables. status is a documented String (not a Prisma enum) to match the house style (layout, rarity are also plain strings).
Why Postgres, not Redis: Redis is the queue's infrastructure; sync bookkeeping is durable reference data that belongs next to the cards it describes (already in Postgres) and benefits from Prisma's transactional writes. Run non-overlap is BullMQ's job, not this table's — hence no lock column.
3. Redis is self-hosted, in both environments — no managed provider
- Dev: a minimal
docker-compose.ymlat the repo root with a singleredisservice. Postgres stays on Supabase cloud (unchanged); only Redis is containerized locally. Start withdocker compose up -d redis. - Prod: a
rediscontainer added todeploy/compose.ymlon the VPS, attached to the internal Docker network only — never routed through Traefik, never exposed to the internet. Persistence via a named volume + AOF (append-only file); password-protected (requirepass) as defense-in-depth.
The same Redis image and configuration run in dev and prod (parity).
Upstash (managed Redis) is explicitly rejected — see rationale. This revises the earlier roadmap note ("Redis Docker for dev, Upstash for production", Phase 9.2), written before the VPS Docker stack existed.
Rationale
Why BullMQ + Redis now, on a job that doesn't strictly need it:
- Phase 9 (PDF generation) needs a real queue, incompressibly — users will trigger jobs concurrently, with status polling and retries. That is the canonical queue use case.
- Introducing the infra now, cold, on a stakes-free job is the cheap time to learn and harden it. When Phase 9 arrives under real user load, Redis + BullMQ will already be deployed and understood — we de-risk in advance instead of under pressure.
- The roadmap already commits to it (Phase 3.2, Phase 9.2).
The accepted cost: one more running service for a job that, today, a setInterval could handle. Paid now, at rest, rather than later.
Why self-hosted, not Upstash:
- BullMQ + per-command billing is a known trap. A BullMQ worker holds a persistent connection and continuously polls the queue with blocking commands — a steady stream of Redis commands even when idle. On Upstash's pay-per-command model this burns a free-tier quota continuously. BullMQ also needs a native TCP Redis connection (
maxRetriesPerRequest: nullfor blocking commands), not a REST endpoint. - We already run the Docker infrastructure (Traefik +
deploy/compose.ymlon the VPS). Adding a Redis container is trivial and free, and sidesteps the billing/connection friction entirely. - Dev/prod parity: the same Redis everywhere removes a class of "works locally, breaks in prod" bugs.
Why internal-network-only in prod: Redis has no business being reachable from the internet. Only the worker (and later the API, for enqueuing) connects to it, over the internal Docker network. Not exposing it through Traefik is the correct security posture; the password is a second layer.
Trade-offs
Benefits:
- One queue system for all background work (sync now, PDF later) — no second mechanism to introduce.
- No external dependency, no per-command billing surprises, dev/prod parity.
- Resolves the long-standing "no local dev docker-compose" blocker, scoped to just Redis.
Costs:
- We own Redis's health (restart, disk for AOF). Minimal for a single
restart: unless-stoppedcontainer. - Slightly heavier local dev setup: contributors need Docker running for the worker (not for web/api, which stay native against Supabase).
Risks:
- Redis data loss (queue state) on crash. Low impact:
scryfall-syncis idempotent and re-runnable; a lost daily job simply reruns next cron. AOF persistence covers most cases. - Version/beta drift: BullMQ pins to a Redis major it supports — track compatibility on upgrade.
Consequences / Follow-ups
- New dependencies:
bullmq(+ itsioredispeer) inapps/worker.apps/workergains a realpackage.json(currently a bare shell). - New file: root
docker-compose.yml(dev Redis) — resolves the roadmap "docker-compose.yml — local dev infra" item, scoped to Redis only. - New env var:
REDIS_URL(e.g.redis://localhost:6379in dev; internal service name in prod). Add to.env.example. - Deferred to a follow-up (not this session): deploying the worker itself in prod as a 4th Docker image (api / web / statics / worker) added to the deploy pipeline and
deploy/compose.yml, alongside the prod Redis container. - The
SyncStatetable (decision 2 above) is added to the Prisma schema via the normal schema-change flow (db-reviewer→db:push) — no separate ADR or decisions-log entry.
References
- ADR-0030 — Worker Database Access (the worker writes reference data via Prisma; this ADR gives it a scheduler and a queue)
- ADR-0026 — Reverse proxy (Traefik) +
deploy/compose.ymlstack the prod Redis container joins - Phase 3.2 (roadmap) — Scryfall initial data sync; Phase 9.2 — PDF worker (the real queue need)
- Phase 3.1 —
packages/scryfallbulk client the sync job composes