Tabular generate replaced with a durable job queue and worker pool
The biggest structural change in the tabular review subsystem: +1650/-475 in a single commit, swapping the SSE-streaming generate handler for a jobs table with leased worker claims. The author's stated target is 5K-10K-document reviews; the proximate problem was that proxy idle timeouts, tab closes, and backend restarts all killed runs with no recovery path.
Migration 005_tabular_jobs.sql introduces three objects: tabular_jobs (per-review job with status and counters), tabular_job_items (one row per document-to-process), and claim_tabular_job_item(lease_seconds) - a SQL RPC wrapping SELECT ... FOR UPDATE SKIP LOCKED so multiple backend instances can claim items without races. Items whose lease_expires_at has passed are treated as unclaimed, so a worker that crashes releases its items automatically when the next scan runs. The partial index on tabular_job_items(created_at) WHERE status IN ('pending','running') keeps the claim query fast as completed rows accumulate.
Business logic moved from routes/tabular.ts into lib/tabularJobs.ts (832 LOC) so the TabularWorkerPool class doesn't import a route file - a clean separation that matters once the worker starts before app.listen() and shuts down on SIGTERM/SIGINT. The generate endpoint now creates a job and returns immediately; four new endpoints handle polling: GET /jobs/:id, GET /jobs/:id/cells, POST /jobs/:id/cancel, and GET /reviews/:id/active-job. The last one is what enables resume-on-page-reload.
The frontend replaces an EventSource with a pollJob loop in TabularReviewView.tsx. Live progress shows as a "12/200" counter. On remount, the active-job endpoint is checked first; if a job is running it resumes polling from wherever it left off.
Configurable via env: TABULAR_GENERATE_CONCURRENCY (default 10), TABULAR_JOB_LEASE_SECONDS (300), TABULAR_WORKER_IDLE_MS (500), NEXT_PUBLIC_TABULAR_POLL_MS (1500). One acknowledged tradeoff: per-cell streaming is gone - a document's full row of cells appears together when the item finishes rather than cell-by-cell.
Spotted something wrong? Or know the PR text has fresher detail than the writeup above?