From 73884455f487a5734c9f8c604e01b8e7df6721e8 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 26 Jun 2026 03:57:20 -0700 Subject: [PATCH] perf(selfhost): default QUEUE_CONCURRENCY to 4 so PR bursts drain in parallel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review jobs are I/O-bound — GitHub API + AI awaits dominate wall time — so the serial default (QUEUE_CONCURRENCY=1) made a burst of N PRs take N times the slowest review back to back. Default to 4 overlapping job loops: SQLite's WAL + busy_timeout absorb the short serialized write windows, and the Postgres queue's FOR UPDATE SKIP LOCKED keeps claims race-free across the pool/replicas. QUEUE_CONCURRENCY=1 restores strict serial. --- .env.example | 2 +- docs/self-hosting.md | 3 ++- src/selfhost/pg-queue.ts | 6 ++++-- src/selfhost/sqlite-queue.ts | 6 ++++-- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 6661813b6f..34c478edfa 100644 --- a/.env.example +++ b/.env.example @@ -151,7 +151,7 @@ GITTENSORY_REVIEW_DRAFT=false # LITESTREAM_REGION=us-east-1 # --- Queue worker (#977/#1201) --- -# QUEUE_CONCURRENCY=1 # max concurrent job-processing loops per instance (default 1) +# QUEUE_CONCURRENCY=4 # max concurrent (I/O-bound) job loops per instance (default 4; 1 = serial) # --- Caddy HTTPS terminator (#1203; requires --profile caddy) --- # DOMAIN=gittensory.example.com # fully-qualified domain; Caddy auto-obtains a Let's Encrypt cert diff --git a/docs/self-hosting.md b/docs/self-hosting.md index f4fe5066ae..87385b6338 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -117,7 +117,8 @@ the **Orb-brokered** path: (You set **no** `GITHUB_APP_*` secrets — the Orb holds the App key and mints tokens for you on demand.) Runtime knobs: `PORT` (default 8787), `DATABASE_PATH` (default `/data/gittensory.sqlite`), `CRON_INTERVAL_MS` -(default 120000 ≈ the hosted every-2-minutes cron). +(default 120000 ≈ the hosted every-2-minutes cron), `QUEUE_CONCURRENCY` (default 4 — how many I/O-bound review +jobs process at once; raise it to drain bigger PR bursts faster, set `1` for strict serial processing). **Secrets via files.** Any `FOO_FILE=/run/secrets/foo` is read into `FOO` at startup (Docker/Compose secrets, multi-line keys) — an explicit `FOO` always wins. diff --git a/src/selfhost/pg-queue.ts b/src/selfhost/pg-queue.ts index ce21a5cbc8..163a087be9 100644 --- a/src/selfhost/pg-queue.ts +++ b/src/selfhost/pg-queue.ts @@ -40,7 +40,9 @@ export interface PgQueueOptions { maxRetries?: number; pollIntervalMs?: number; backoffMs?: (attempt: number) => number; - /** Max concurrent `processOne()` loops. Defaults to QUEUE_CONCURRENCY env var or 1. */ + /** Max concurrent `processOne()` loops. Defaults to QUEUE_CONCURRENCY env var or 4 — review jobs are I/O-bound + * (GitHub + AI awaits dominate), so overlapping a handful drains a PR burst far faster; FOR UPDATE SKIP LOCKED + * keeps claims race-free across the pool (and across replicas). Set QUEUE_CONCURRENCY=1 to force strict serial. */ concurrency?: number; } @@ -48,7 +50,7 @@ export function createPgQueue(pool: Pool, consume: (message: JobMessage) => Prom const maxRetries = opts.maxRetries ?? 5; const pollIntervalMs = opts.pollIntervalMs ?? 1000; const backoff = opts.backoffMs ?? ((attempt: number) => Math.min(60_000, 1000 * 2 ** attempt)); - const concurrency = opts.concurrency ?? Math.max(1, Number(process.env.QUEUE_CONCURRENCY ?? "1")); + const concurrency = opts.concurrency ?? Math.max(1, Number(process.env.QUEUE_CONCURRENCY ?? "4")); let running = false; let active = 0; diff --git a/src/selfhost/sqlite-queue.ts b/src/selfhost/sqlite-queue.ts index 898f35ef91..710d0b36b4 100644 --- a/src/selfhost/sqlite-queue.ts +++ b/src/selfhost/sqlite-queue.ts @@ -40,7 +40,9 @@ export interface SqliteQueueOptions { maxRetries?: number; pollIntervalMs?: number; backoffMs?: (attempt: number) => number; - /** Max concurrent `processOne()` loops. Defaults to QUEUE_CONCURRENCY env var or 1. */ + /** Max concurrent `processOne()` loops. Defaults to QUEUE_CONCURRENCY env var or 4 — review jobs are I/O-bound + * (GitHub + AI awaits dominate), so overlapping a handful drains a PR burst far faster while SQLite's WAL + + * busy_timeout absorb the short serialized write windows. Set QUEUE_CONCURRENCY=1 to force strict serial. */ concurrency?: number; } @@ -48,7 +50,7 @@ export function createSqliteQueue(driver: SqliteDriver, consume: (message: JobMe const maxRetries = opts.maxRetries ?? 5; const pollIntervalMs = opts.pollIntervalMs ?? 1000; const backoff = opts.backoffMs ?? ((attempt: number) => Math.min(60_000, 1000 * 2 ** attempt)); - const concurrency = opts.concurrency ?? Math.max(1, Number(process.env.QUEUE_CONCURRENCY ?? "1")); + const concurrency = opts.concurrency ?? Math.max(1, Number(process.env.QUEUE_CONCURRENCY ?? "4")); driver.exec(DDL); // Recover jobs a crashed previous run left mid-flight → make them claimable again.