From 32cb75c1aa8ee4b236ec3ccab1d73006e562f4e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 22:57:30 +0200 Subject: [PATCH] fix(db): let the maintenance job run behind a pooler that refuses startup options --- docs/self-hosting/scaling.md | 3 ++ .../measurement-maintenance-session.test.ts | 30 +++++++++++++++++++ src/lib/jobs/measurement-maintenance.ts | 24 ++++++++++++++- 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/lib/jobs/__tests__/measurement-maintenance-session.test.ts diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 7f3835c7a..2512ecad9 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -195,6 +195,9 @@ in `ignore_startup_parameters`. Either list it there, or set `DATABASE_SESSION_OPTIONS_DISABLED=1` so the app sends no session settings; set the timeout and `work_mem` on the database role instead (`ALTER ROLE healthlog SET work_mem = '16MB'`) if you still want them. +The measurement maintenance job (Admin, System status) then applies its own +settings with `SET` after connecting. Its concurrent index rebuild needs a +session, so run it through a session-mode pool or a direct connection. ### Overriding the per-container pool ceiling diff --git a/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts b/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts new file mode 100644 index 000000000..e2b6ffe9c --- /dev/null +++ b/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: {}, + sessionOptionsDisabled: () => + process.env.DATABASE_SESSION_OPTIONS_DISABLED === "1", +})); + +import { + MAINTENANCE_SESSION_OPTIONS, + maintenanceSessionStatements, +} from "@/lib/jobs/measurement-maintenance"; + +describe("maintenance session settings behind a pooler", () => { + afterEach(() => { + delete process.env.DATABASE_SESSION_OPTIONS_DISABLED; + }); + + it("restates every startup option as one SET statement", () => { + const statements = maintenanceSessionStatements(); + const optionCount = MAINTENANCE_SESSION_OPTIONS.match(/-c /g)?.length; + expect(statements).toHaveLength(optionCount ?? 0); + expect(statements).toEqual([ + "SET statement_timeout = '0'", + "SET idle_in_transaction_session_timeout = '0'", + "SET maintenance_work_mem = '128MB'", + "SET lock_timeout = '300000'", + ]); + }); +}); diff --git a/src/lib/jobs/measurement-maintenance.ts b/src/lib/jobs/measurement-maintenance.ts index c990f7d65..fa5305baf 100644 --- a/src/lib/jobs/measurement-maintenance.ts +++ b/src/lib/jobs/measurement-maintenance.ts @@ -35,6 +35,7 @@ */ import { Client } from "pg"; import type { Job } from "pg-boss"; +import { sessionOptionsDisabled } from "@/lib/db"; import { withJobLock } from "@/lib/jobs/job-lock"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; @@ -68,6 +69,21 @@ export interface MeasurementMaintenancePayload { export const MAINTENANCE_SESSION_OPTIONS = "-c statement_timeout=0 -c idle_in_transaction_session_timeout=0 -c maintenance_work_mem=128MB -c lock_timeout=300000"; +/** + * The same settings as `SET` statements, for a host behind a connection + * pooler that refuses the `options` startup parameter + * (`DATABASE_SESSION_OPTIONS_DISABLED`). Built from the constant above, whose + * every value is a literal, so nothing user-supplied reaches the SQL. + */ +export function maintenanceSessionStatements(): string[] { + return MAINTENANCE_SESSION_OPTIONS.split(/\s*-c\s+/) + .filter(Boolean) + .map((pair) => { + const [name, value] = pair.trim().split("="); + return `SET ${name} = '${value}'`; + }); +} + /** The only shape an index name may have before it is spliced into SQL. */ const INDEX_NAME = /^[a-z0-9_]{1,63}$/; @@ -310,14 +326,20 @@ export async function handleMeasurementMaintenance( const guarded = await withJobLock( MEASUREMENT_MAINTENANCE_QUEUE, async () => { + const viaStatements = sessionOptionsDisabled(); const client = new Client({ connectionString: process.env.DATABASE_URL, - options: MAINTENANCE_SESSION_OPTIONS, + options: viaStatements ? undefined : MAINTENANCE_SESSION_OPTIONS, keepAlive: true, }); client.on("error", () => {}); await client.connect(); try { + if (viaStatements) { + for (const statement of maintenanceSessionStatements()) { + await client.query(statement); + } + } return await runMeasurementMaintenance(client, payload); } finally { await client