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.