Content Automation v2 - Deleting 2,400 Lines and a Class of Bugs
Deleted a Redis-backed Railway worker and BullMQ entirely by turning Postgres into the job queue, cutting ~2,400 lines, ~85% of scheduled invocations, and a whole class of silent distributed-systems bugs.
Problem
fara.social is an internal LinkedIn and Instagram content-automation platform that ingests RSS feeds, generates AI-written posts, lets users approve and schedule content, and publishes to platform APIs at the right time. In V1, every one of those steps was a background job: a long-running Railway worker pulled work from Upstash Redis via BullMQ while the Next.js web app on Vercel enqueued it - three systems coordinating across two runtimes. The hidden cost of that design accumulated steadily: two deployment targets to secret-sync and reason about, two sources of truth (Post.status in Postgres vs. job state in Redis that could silently disagree), and in-memory deduplication state that was lost on worker restarts. The encryption setup added another latent hazard: a single AES-256-GCM key shared between the app and the worker meant any key rotation or divergence made every stored OAuth token unreadable, surfacing only as silent publish failures. The architecture collapsed visibly in production when approved posts piled up in QUEUED and never reached PUBLISHED or FAILED. The web app's queue stats (read from Redis) disagreed with the database, publishing was never even attempted, and no single component logged an error - the classic "no error, just stuck" fingerprint of a dropped handoff across a process-and-network boundary. The question that followed was not how to patch it, but whether any of the machinery was necessary.
Solution
V2 deletes the Railway worker, Upstash Redis, and BullMQ entirely. Postgres becomes the only queue: a status column (DRAFT → APPROVED → QUEUED → PUBLISHING → PUBLISHED / FAILED) plus a handful of metadata columns (scheduleAt, lockedAt, enqueuedAt, attemptCount) carry all job state. Five Vercel Cron routes - publish every 5 minutes, RSS every 15, draft generation every 30, weekly planning once a week, token monitoring once a day - drain due rows by calling plain functions in src/lib. The "don't double-process" guarantee is a single conditional UPDATE that only succeeds if the row is still in the expected status, giving atomic compare-and-swap semantics with no locks and no Redis. A reclaimStuckPosts sweep at the top of each publish run resets any row whose lockedAt is older than 15 minutes, replacing BullMQ's stalled-job recovery in a few lines. Retry state (attemptCount) lives in the row and survives restarts for free. The migration itself was zero-downtime: because claiming is idempotent and atomic, the old worker and the new cron ran against the same database simultaneously - whoever claimed a row first won, the other got null and moved on. Cron routes were shipped while the worker still ran, publishing was confirmed via a lastPublishedAt liveness signal, and then the worker and Redis were switched off with no maintenance window. The V1→V2 work also bundled a Next.js upgrade from 15 to 16, which introduced Turbopack as the default builder, renamed middleware.ts to proxy.ts, and enforced async Request APIs. The upgrade's peer-dependency friction exposed that next-auth was dead code - a placeholder shim importing nothing, while real auth ran entirely through Supabase - so it was deleted outright rather than migrated. A versioned AES-256-GCM keyring replaced the single-key encryption scheme so that key rotation no longer silently invalidates all stored OAuth tokens. Read-only diagnostic endpoints (including a plain-English diagnosis and a ?publish=now override) round out the observability story: when the queue is the database, the debugger is SELECT.