From d6b5902c5a1378ce99dc19ffa0645818375d2cc1 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:10:43 -0700 Subject: [PATCH] fix(selfhost): make the Postgres connection pool size operator-tunable The self-host Postgres connection pool is created with no `max` option, so it silently uses the pg driver's hardcoded default of 10 connections. That single pool is shared by every HTTP handler AND every queue worker's own database operations, including jobs that intentionally fan out several concurrent writes (e.g. hydrateMergedPullRequestFiles). A realistic burst -- a handful of concurrent job workers each running a fan-out step, plus normal webhook traffic -- can plausibly want more concurrent connections than the pool provides, well before Postgres's own connection ceiling or the existing GittensoryPostgresConnectionPressure alert would trip. There was no environment variable to raise this without a code change. - Add PGPOOL_MAX, wired into the pool construction in src/server.ts via a small extracted, directly-testable resolvePostgresPoolMax() in queue-common.ts (server.ts itself has no test infrastructure -- it boots a real server at module load and is Codecov-ignored for exactly that reason -- so the resolution logic needed to live somewhere side-effect-free to be unit-tested at all). - Default (10) matches the pg driver's own prior implicit default exactly, so this is a pure opt-in tuning knob with no behavior change when unset. - Documented in .env.example and the self-hosting backup/scaling doc, including the distinction from PgBouncer (which pools connections between instances and Postgres; this pools connections within one instance) and what to watch (GittensoryPostgresConnectionPressure) before raising it further. Validation: full local gate green; resolvePostgresPoolMax() is fully unit tested (default, override, invalid-value fallback). src/server.ts itself carries no Codecov patch-coverage obligation (already ignored in codecov.yml, alongside pg-adapter.ts/pg-queue.ts, as self-host process-entry code validated by the Docker build+boot smoke test instead). --- .env.example | 7 ++++++ .../docs.self-hosting-backup-scaling.tsx | 10 ++++++++ src/selfhost/queue-common.ts | 11 +++++++++ src/server.ts | 3 ++- test/unit/selfhost-queue-common.test.ts | 24 +++++++++++++++++++ 5 files changed, 54 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index c1bb760ea6..8e53a488b1 100644 --- a/.env.example +++ b/.env.example @@ -165,6 +165,13 @@ GITTENSORY_REVIEW_DRAFT=false # PGVECTOR_ENABLED=false # set true only when using the Postgres pgvector table for RAG. # # Leave false when QDRANT_URL is set; Qdrant remains the preferred # # dedicated vector store for review context at scale. +# PGPOOL_MAX=10 # max concurrent Postgres connections in the pool shared by every +# # HTTP handler AND every queue worker's own DB traffic. 10 (this +# # default) is fine for a small/idle instance; raise it if you +# # register many repos or run higher QUEUE_CONCURRENCY, and you see +# # request/job latency climb without Postgres itself under pressure +# # (check GittensoryPostgresConnectionPressure and the app's own +# # request-latency metrics to tell those two cases apart). REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review runtime. The default compose stack # # starts Redis automatically; override for an external Redis. # GITTENSORY_IMAGE=ghcr.io/jsonbored/gittensory-selfhost:latest # image used by scripts/deploy-selfhost-image.sh; diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-backup-scaling.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-backup-scaling.tsx index ed012253fb..422d53b5f2 100644 --- a/apps/gittensory-ui/src/routes/docs.self-hosting-backup-scaling.tsx +++ b/apps/gittensory-ui/src/routes/docs.self-hosting-backup-scaling.tsx @@ -88,6 +88,16 @@ REDIS_URL=redis://redis:6379 QDRANT_URL=http://qdrant:6333`} /> +

+ PgBouncer pools connections between instances and Postgres. Each app instance still + opens its own connection pool to whatever it's pointed at (PgBouncer or Postgres directly), + shared by every HTTP handler and queue worker in that instance — set PGPOOL_MAX{" "} + (default 10) if a single instance needs more headroom than that under real concurrency (many + registered repos, higher QUEUE_CONCURRENCY). Raise it gradually and watch for{" "} + GittensoryPostgresConnectionPressure: that alert means you're approaching + Postgres's own max_connections, a different ceiling than this per-instance pool + size. +

One-time SQLite to Postgres copy

diff --git a/src/selfhost/queue-common.ts b/src/selfhost/queue-common.ts index 141e5bf48a..d3fc47f809 100644 --- a/src/selfhost/queue-common.ts +++ b/src/selfhost/queue-common.ts @@ -568,6 +568,17 @@ export function queueStartupJitterMinJobs(): number { return parsePositiveIntEnv("QUEUE_STARTUP_JITTER_MIN_JOBS", { min: 0, fallback: DEFAULT_STARTUP_JITTER_MIN_JOBS }); } +// The Postgres pool (src/server.ts's buildPostgresBackend) is shared by every HTTP handler AND every queue +// worker's own DB traffic, including jobs that fan out several concurrent writes (e.g. +// hydrateMergedPullRequestFiles). 10 (pg's own hardcoded default, made explicit here rather than left +// implicit) is fine for a small/idle instance but can bottleneck the app on its own connection pool -- +// well before Postgres's own max_connections or the GittensoryPostgresConnectionPressure alert would fire +// -- once webhook bursts and fan-out jobs overlap at real volume. PGPOOL_MAX lets an operator raise this +// without a code change (#audit-rate-headroom). +export function resolvePostgresPoolMax(): number { + return parsePositiveIntEnv("PGPOOL_MAX", { min: 1, fallback: 10 }); +} + export function deterministicJitterMs(seed: string, maxJitterMs: number): number { if (!Number.isFinite(maxJitterMs) || maxJitterMs <= 0) return 0; let h = 2166136261; diff --git a/src/server.ts b/src/server.ts index 83c9a4d17c..ec5c21e8c5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -48,6 +48,7 @@ import { runSelfHostMigrations } from "./selfhost/migrate"; import { createPgAdapter } from "./selfhost/pg-adapter"; import { createPgQueue } from "./selfhost/pg-queue"; import { createPgVectorize, initPgVectorize } from "./selfhost/pg-vectorize"; +import { resolvePostgresPoolMax } from "./selfhost/queue-common"; import { createSqliteQueue } from "./selfhost/sqlite-queue"; import { createSqliteVectorize } from "./selfhost/vectorize"; import { createFsBlobStore } from "./selfhost/blob-store"; @@ -204,7 +205,7 @@ async function buildPostgresBackend( await waitForPostgres(url); const pg = (await import("pg")).default; pg.types.setTypeParser(20, (v: string) => Number.parseInt(v, 10)); // int8 (COUNT) → number, like D1 - const pool = new pg.Pool({ connectionString: url }); + const pool = new pg.Pool({ connectionString: url, max: resolvePostgresPoolMax() }); const db = createPgAdapter(pool); const queue = createPgQueue(pool, consume); await queue.init(); diff --git a/test/unit/selfhost-queue-common.test.ts b/test/unit/selfhost-queue-common.test.ts index 6e150cc494..0780eba652 100644 --- a/test/unit/selfhost-queue-common.test.ts +++ b/test/unit/selfhost-queue-common.test.ts @@ -30,6 +30,7 @@ import { queueSnapshotFromBinding, queueStartupJitterMinJobs, queueStartupJitterMs, + resolvePostgresPoolMax, scheduledEnqueueDelaySeconds, scheduledEnqueueJitterMs, } from "../../src/selfhost/queue-common"; @@ -1169,3 +1170,26 @@ describe("parsePositiveIntEnv", () => { expect(warn).not.toHaveBeenCalled(); }); }); + +describe("resolvePostgresPoolMax (#audit-rate-headroom)", () => { + afterEach(() => { + delete process.env.PGPOOL_MAX; + }); + + it("defaults to 10 (pg's own hardcoded default, made explicit) when PGPOOL_MAX is unset", () => { + delete process.env.PGPOOL_MAX; + expect(resolvePostgresPoolMax()).toBe(10); + }); + + it("honors a valid PGPOOL_MAX override", () => { + process.env.PGPOOL_MAX = "25"; + expect(resolvePostgresPoolMax()).toBe(25); + }); + + it("falls back to the default for an invalid value, so a typo never disables pooling entirely", () => { + process.env.PGPOOL_MAX = "not-a-number"; + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(resolvePostgresPoolMax()).toBe(10); + expect(warn).toHaveBeenCalledOnce(); + }); +});