fix(db): let a PgBouncer host turn the session startup options off

The statement timeout and, since v1.42, work_mem reach Postgres as the
libpq options startup parameter, which PgBouncer refuses unless it is in
ignore_startup_parameters. A host behind it could drop the string by
setting the timeout to 0; work_mem is now always sent, so that no longer
worked. DATABASE_SESSION_OPTIONS_DISABLED sends no session settings,
documented in the example env, the env manifest, the compose whitelist
and the scaling guide.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:37:57 +02:00
1 parent f950dffad4
commit 7c6510eba9
6 files changed
+54 -2

No files matched your search

+7
View File
@@ -456,6 +456,13 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 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"
# -----------------------------------------------------------------------------
+3
View File
@@ -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
+8
View File
@@ -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
+4
View File
@@ -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."
}
]
},
+11
View File
@@ -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");
+21 -2
View File
@@ -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