diff --git a/.env.production.example b/.env.production.example index 8804e9169..c5f56207c 100644 --- a/.env.production.example +++ b/.env.production.example @@ -456,6 +456,13 @@ API_TOKEN_HMAC_KEY="" # sort step of each pooled connection may use this much memory, so raise it # with the pool size in mind. # DATABASE_WORK_MEM="16MB" +# +# Both settings above reach Postgres as the libpq `options` startup +# parameter. PgBouncer refuses that parameter ("unsupported startup +# parameter: options") unless it is listed in its ignore_startup_parameters. +# Behind such a pooler, set this to 1, true or yes to send no session +# settings at all; the pooler's or the database's own defaults then apply. +# DATABASE_SESSION_OPTIONS_DISABLED="1" # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 05e8ed510..02a2b3257 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,9 @@ x-healthlog-env: &healthlog_env DATABASE_STATEMENT_TIMEOUT_MS: "${DATABASE_STATEMENT_TIMEOUT_MS:-}" # Postgres work_mem for every app session (default 16MB). DATABASE_WORK_MEM: "${DATABASE_WORK_MEM:-}" + # 1 sends neither of the two settings above (they ride the libpq `options` + # startup parameter, which PgBouncer refuses by default). + DATABASE_SESSION_OPTIONS_DISABLED: "${DATABASE_SESSION_OPTIONS_DISABLED:-}" WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}" # Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`; # set it when the URL registered with Withings differs from the app's own diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 6776d44a3..7f3835c7a 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -188,6 +188,14 @@ already`. Either raise `max_connections` on the Postgres side, lower `DATABASE_POOL_MAX`, or front the database with PgBouncer in transaction-pooling mode. +The app sets its statement timeout and `work_mem` on every connection +through the libpq `options` startup parameter. PgBouncer refuses that +parameter (`unsupported startup parameter: options`) unless it is listed +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. + ### Overriding the per-container pool ceiling With the bundled compose, set `DB_CONNECTION_LIMIT` in `.env`; it diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 0a2e46ab2..08a6796ec 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -217,6 +217,10 @@ { "name": "DATABASE_WORK_MEM", "purpose": "Postgres work_mem for every app session, as a Postgres size string. Default 16MB. Raise it when large sorts and aggregates spill to disk; each sort step of each connection may use this much." + }, + { + "name": "DATABASE_SESSION_OPTIONS_DISABLED", + "purpose": "Set to 1, true or yes to send no session settings (statement timeout, work_mem) as the libpq options startup parameter. Needed behind PgBouncer, which refuses that parameter unless it is in ignore_startup_parameters." } ] }, diff --git a/src/lib/__tests__/db.test.ts b/src/lib/__tests__/db.test.ts index 95f704c5b..5cf7c93e6 100644 --- a/src/lib/__tests__/db.test.ts +++ b/src/lib/__tests__/db.test.ts @@ -146,6 +146,17 @@ describe("buildSessionOptions", () => { ); }); + it("sends no startup options at all when a connection pooler needs that", () => { + process.env.DATABASE_SESSION_OPTIONS_DISABLED = "true"; + try { + expect(buildSessionOptions()).toBeUndefined(); + process.env.DATABASE_SESSION_OPTIONS_DISABLED = "0"; + expect(buildSessionOptions()).toContain("-c work_mem="); + } finally { + delete process.env.DATABASE_SESSION_OPTIONS_DISABLED; + } + }); + it("takes DATABASE_WORK_MEM and refuses a value that would inject a flag", () => { process.env.DATABASE_WORK_MEM = "64MB"; expect(buildSessionOptions()).toContain("-c work_mem=64MB"); diff --git a/src/lib/db.ts b/src/lib/db.ts index 5d9cfbf48..bfa815ae1 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -108,6 +108,22 @@ export function getWorkMem(): string { return raw && WORK_MEM_SHAPE.test(raw) ? raw : DEFAULT_WORK_MEM; } +/** + * Whether the app may send the libpq `options` startup parameter at all. + * + * PgBouncer refuses a connection that carries a startup parameter it does not + * know (`unsupported startup parameter: options`) unless the operator lists + * it in `ignore_startup_parameters`. Up to v1.41 such a host could set the + * statement timeout to 0, which dropped the whole string; v1.42 always sends + * `work_mem`, so that no longer worked. `DATABASE_SESSION_OPTIONS_DISABLED` + * (1, true or yes) sends none of the session settings; the pooler or the + * database's own defaults then apply. + */ +export function sessionOptionsDisabled(): boolean { + const raw = process.env.DATABASE_SESSION_OPTIONS_DISABLED?.trim(); + return raw !== undefined && /^(1|true|yes)$/i.test(raw); +} + /** * Build the libpq `options` startup string applying the session settings. * Passed straight through `PrismaPg` to the underlying `pg.Pool`, which @@ -116,9 +132,12 @@ export function getWorkMem(): string { * * The timeouts are left out when disabled (timeout 0); `work_mem` is always * set. Up to v1.41 the whole string was `undefined` at timeout 0, which would - * have taken `work_mem` with it. + * have taken `work_mem` with it. `undefined` now means exactly one thing: + * the operator turned the startup options off for a connection pooler + * ({@link sessionOptionsDisabled}). */ -export function buildSessionOptions(): string { +export function buildSessionOptions(): string | undefined { + if (sessionOptionsDisabled()) return undefined; const timeoutMs = getStatementTimeoutMs(); const timeouts = timeoutMs > 0