From b951b6ee93e22862125a5c31755023973d1a3039 Mon Sep 17 00:00:00 2001 From: "kanawoot.k" <160588725+maritonx@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:44:18 +0700 Subject: [PATCH] feat(db-postgres): pool options, tested behind PgBouncer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `prepare`, `statementTimeoutMs`, `idleTimeoutMs`, `connectTimeoutMs` and `applicationName` (default `PGAPPNAME`, then `easy-cms`). Only options that are set reach postgres.js, so the URL and PG* variables still apply. CI runs the Postgres integration suites through PgBouncer 1.26 in transaction mode with prepared statements off. The Databases guide (EN/TH) covers pool sizing, serverless and poolers. ADR-0055 and the roadmap use the `…Ms` names, like `busyTimeoutMs` of the SQLite adapter. Closes #155 Co-Authored-By: Claude Opus 5.5 --- .changeset/postgres-pool-options.md | 5 ++ .github/workflows/ci.yml | 38 +++++++++++++ docs/ROADMAP.md | 2 +- docs/adr/0055-database-isolation.md | 2 +- packages/db-postgres/README.md | 7 ++- packages/db-postgres/src/index.ts | 44 ++++++++++++-- packages/db-postgres/test/connection.test.ts | 57 +++++++++++++++++++ packages/integration/test/helpers.ts | 18 ++++-- .../integration/test/postgres-pool.test.ts | 27 +++++++++ website/guide/databases.md | 38 +++++++++++++ website/th/guide/databases.md | 32 +++++++++++ 11 files changed, 259 insertions(+), 11 deletions(-) create mode 100644 .changeset/postgres-pool-options.md create mode 100644 packages/db-postgres/test/connection.test.ts create mode 100644 packages/integration/test/postgres-pool.test.ts diff --git a/.changeset/postgres-pool-options.md b/.changeset/postgres-pool-options.md new file mode 100644 index 00000000..ce1d61a8 --- /dev/null +++ b/.changeset/postgres-pool-options.md @@ -0,0 +1,5 @@ +--- +'@easy-cms/db-postgres': minor +--- + +Postgres connection options: `prepare`, `statementTimeoutMs`, `idleTimeoutMs`, `connectTimeoutMs` and `applicationName`. Connections are now named `easy-cms` in `pg_stat_activity` (or `PGAPPNAME`). Options left unset still come from the URL and the `PG*` environment variables. The Databases guide covers pool sizing, serverless and poolers such as PgBouncer. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf29779b..7b75593e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,6 +128,44 @@ jobs: - run: pnpm build - run: pnpm --filter easy-cms-integration-tests exec vitest run --project postgres + pgbouncer: + needs: scope + if: needs.scope.outputs.full == 'true' + # The same suites through PgBouncer in transaction mode, as serverless hosts' poolers run: + # any server connection per transaction, no prepared statements. + runs-on: ubuntu-24.04 + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: easycms + POSTGRES_PASSWORD: easycms + POSTGRES_DB: easycms + options: >- + --health-cmd "pg_isready -U easycms" --health-interval 5s --health-timeout 5s --health-retries 10 + pgbouncer: + image: edoburu/pgbouncer:v1.26.0-p0 + env: + DATABASE_URL: postgres://easycms:easycms@postgres:5432/easycms + POOL_MODE: transaction + AUTH_TYPE: scram-sha-256 + MAX_CLIENT_CONN: 200 + DEFAULT_POOL_SIZE: 20 + ports: ['6432:5432'] + env: + POSTGRES_URL: postgres://easycms:easycms@localhost:6432/easycms + POSTGRES_PREPARE: 'false' + steps: + - uses: actions/checkout@v7 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm --filter easy-cms-integration-tests exec vitest run --project postgres + upgrade: needs: scope if: needs.scope.outputs.full == 'true' diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d947463b..174eda8b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -113,7 +113,7 @@ |---|---|---|---| | [#153](https://github.com/maritonx/easy-cms/issues/153) | Claim scheduled jobs and webhook deliveries with `SKIP LOCKED` | area:core, release-blocker | เทสต์สอง instance บน Postgres เดียวกัน: ทุกงานรันครั้งเดียว; งานของ worker ที่ตายกลับเข้าคิวเมื่อ lease หมด ([ADR-0052](adr/0052-postgres-job-queue.md)) | | [#154](https://github.com/maritonx/easy-cms/issues/154) | Run two instances against one Postgres in CI | area:core, area:release | CI รัน REST, login, upload ไป S3 และ jobs ผ่านสอง instance หลังตัวกระจายโหลด | -| [#155](https://github.com/maritonx/easy-cms/issues/155) | Configurable Postgres pool and `prepare`, tested behind PgBouncer | area:core | ตั้ง `prepare`, `statementTimeout`, `idleTimeout`, `connectTimeout`, `applicationName` (ค่าปริยาย `'easy-cms'`) ได้, CI รันเทสต์หลักผ่าน PgBouncer แบบ transaction pooling, เอกสารมีสูตรจำนวน connection และคำเตือนสำหรับ serverless ([ADR-0055](adr/0055-database-isolation.md)) | +| [#155](https://github.com/maritonx/easy-cms/issues/155) | Configurable Postgres pool and `prepare`, tested behind PgBouncer | area:core | ตั้ง `prepare`, `statementTimeoutMs`, `idleTimeoutMs`, `connectTimeoutMs`, `applicationName` (ค่าปริยาย `'easy-cms'`) ได้, CI รันเทสต์หลักผ่าน PgBouncer แบบ transaction pooling, เอกสารมีสูตรจำนวน connection และคำเตือนสำหรับ serverless ([ADR-0055](adr/0055-database-isolation.md)) | | [#156](https://github.com/maritonx/easy-cms/issues/156) | Document database isolation levels and test a separate schema | area:docs, area:core | หน้า "Database isolation" (EN/TH) อธิบาย L0–L3, CI รันบน role ที่มี `search_path` เป็น schema ของตัวเอง และตรวจว่าไม่มีตารางใน `public` | | [#157](https://github.com/maritonx/easy-cms/issues/157) | JSON logs with a request id | area:core | ตัวเลือกให้ log เป็น JSON บรรทัดละรายการ มี request id ที่ส่งกลับใน header ด้วย; ค่าปริยายยังอ่านง่ายสำหรับ dev | | [#158](https://github.com/maritonx/easy-cms/issues/158) | `/healthz` and `/readyz` in the Next and Nuxt adapters | area:adapters | สอง endpoint ทำงานเหมือน standalone; `/readyz` เช็กฐานข้อมูลและ migration | diff --git a/docs/adr/0055-database-isolation.md b/docs/adr/0055-database-isolation.md index 82d78ae2..280df8f0 100644 --- a/docs/adr/0055-database-isolation.md +++ b/docs/adr/0055-database-isolation.md @@ -29,7 +29,7 @@ - **L1 ทำสองขั้น:** 1. ตอนนี้: เอกสาร `CREATE SCHEMA` + `ALTER ROLE … SET search_path` (ใช้ได้หลัง PgBouncer เพราะค่าอยู่ที่ role ไม่ใช่ startup parameter) และเทสต์ใน CI ว่าตารางทั้งหมดไปอยู่ใน schema นั้น 2. ทีหลัง: ตัวเลือก `schema` ใน `@easy-cms/db-postgres` ใช้ `pgSchema()` ให้ชื่อตารางระบุ schema เสมอ ไม่ขึ้นกับ `search_path` พร้อมคำสั่ง CLI ย้ายตารางของระบบที่ติดตั้งแล้ว (`ALTER TABLE … SET SCHEMA`) -- **ตัวเลือกของ pool:** `statementTimeout`, `idleTimeout`, `connectTimeout`, `applicationName` (ค่าปริยาย `'easy-cms'`) และ `prepare` ส่วน `statementTimeout` ไม่มีค่าปริยาย เพราะ migration และ backup อาจใช้เวลานาน เอกสารให้สูตร: instance × `max` ไม่เกิน 10–20% ของ `max_connections` และบน serverless ให้ใช้ pooler +- **ตัวเลือกของ pool:** `statementTimeoutMs`, `idleTimeoutMs`, `connectTimeoutMs` (มิลลิวินาที เหมือน `busyTimeoutMs` ของ SQLite), `applicationName` (ค่าปริยาย `'easy-cms'`) และ `prepare` ส่วน `statementTimeoutMs` ไม่มีค่าปริยาย เพราะ migration และ backup อาจใช้เวลานาน เอกสารให้สูตร: instance × `max` ไม่เกิน 10–20% ของ `max_connections` และบน serverless ให้ใช้ pooler - **role แยกสำหรับ migration:** runtime ใช้ role ที่มีแค่ DML ได้ โดย `verify()` ต้องตรวจตาราง migrations โดยไม่ใช้ DDL ส่วน `easy-cms migrate` รันด้วย role เจ้าของ schema (connection string อีกตัว) ## ผลที่ตามมา diff --git a/packages/db-postgres/README.md b/packages/db-postgres/README.md index e5bd8147..4f5de55a 100644 --- a/packages/db-postgres/README.md +++ b/packages/db-postgres/README.md @@ -27,6 +27,11 @@ export default defineConfig({ |---|---|---| | `url` | — | Connection string | | `max` | `10` | Pool size | +| `prepare` | `true` | Prepared statements; `false` behind a pooler that can't keep them | +| `statementTimeoutMs` | — | Cancels longer statements (not behind PgBouncer: set it on the role) | +| `idleTimeoutMs` | — | Closes idle connections after this long | +| `connectTimeoutMs` | `30000` | Gives up opening a connection after this long | +| `applicationName` | `easy-cms` | Shown in `pg_stat_activity` | | `pglite` | — | Data directory (relative to the project), `memory://`, or a `PGlite` instance. Needs `@electric-sql/pglite`. | | `tablePrefix` | `ecms_` | Prefix of every table Easy CMS creates | | `migrationDir` | `easy-cms/migrations` | Where migration files are written | @@ -36,6 +41,6 @@ Text sorting follows the database collation (server defaults like `en_US.UTF-8` ## Links -[Databases](https://easy-cms-website.vercel.app/docs/databases) · [Documentation](https://easy-cms-website.vercel.app/docs) ([ภาษาไทย](https://easy-cms-website.vercel.app/th/docs)) · [GitHub](https://github.com/maritonx/easy-cms) +[Databases](https://easy-cms-website.vercel.app/docs/databases) (connections, poolers, serverless) · [Documentation](https://easy-cms-website.vercel.app/docs) ([ภาษาไทย](https://easy-cms-website.vercel.app/th/docs)) · [GitHub](https://github.com/maritonx/easy-cms) MIT License diff --git a/packages/db-postgres/src/index.ts b/packages/db-postgres/src/index.ts index 2745f79a..5f50e034 100644 --- a/packages/db-postgres/src/index.ts +++ b/packages/db-postgres/src/index.ts @@ -31,6 +31,27 @@ export interface PostgresAdapterOptions extends DrizzleAdapterOptions { readonly url?: string /** Maximum connections in the pool. Default 10. */ readonly max?: number + /** + * Use prepared statements. Default true. Turn off behind a pooler in transaction mode that + * can't keep them (PgBouncer before 1.24, or with `max_prepared_statements = 0`, and some + * hosted poolers): it would run the next query on a server connection without the statement. + */ + readonly prepare?: boolean + /** + * Cancels a statement running longer than this (ms), sent as `statement_timeout` when a + * connection opens. No default: migrations and backups may take a while. PgBouncer refuses + * the parameter; set it on the role there (`ALTER ROLE … SET statement_timeout = …`). + */ + readonly statementTimeoutMs?: number + /** Closes a connection left idle this long (ms). Default: never. */ + readonly idleTimeoutMs?: number + /** Gives up opening a connection after this long (ms). Default 30000. */ + readonly connectTimeoutMs?: number + /** + * `application_name` of the connections, shown in `pg_stat_activity`. Default `PGAPPNAME`, + * then `'easy-cms'`. + */ + readonly applicationName?: string /** * Use PGlite (Postgres in WebAssembly) instead of a server: a data directory * (relative to the project root), `memory://`, or a PGlite instance you manage. @@ -122,15 +143,30 @@ export function postgres(options: PostgresAdapterOptions): DatabaseAdapter { init: async (args) => { const connection = options.pglite ? await pgliteConnection(options.pglite, args.cwd) - : postgresJsConnection(options.url as string, options.max ?? 10) + : postgresJsConnection(options.url as string, options) return connectDatabase(postgresDialect, connection, args, options) }, } } -function postgresJsConnection(url: string, max: number): Connection { - // Keep Easy CMS quiet: Postgres NOTICEs (e.g. "relation already exists, skipping") are expected. - const client = postgresJs(url, { max, onnotice: () => {} }) +function postgresJsConnection(url: string, options: PostgresAdapterOptions): Connection { + // Only what is set: postgres.js lets an option passed here win over the URL (`?prepare=false`) + // and the PG* environment variables. + const set = (key: string, value: T | undefined) => + value === undefined ? {} : { [key]: value } + const seconds = (ms: number | undefined) => (ms === undefined ? undefined : ms / 1000) + const client = postgresJs(url, { + max: options.max ?? 10, + ...set('prepare', options.prepare), + ...set('idle_timeout', seconds(options.idleTimeoutMs)), + ...set('connect_timeout', seconds(options.connectTimeoutMs)), + connection: { + application_name: options.applicationName ?? (process.env.PGAPPNAME || 'easy-cms'), + ...set('statement_timeout', options.statementTimeoutMs), + }, + // Keep Easy CMS quiet: Postgres NOTICEs (e.g. "relation already exists, skipping") are expected. + onnotice: () => {}, + }) const runner: SqlRunner = { async query(text, params = []) { return [...(await client.unsafe(text, [...params] as never[]))] as Record[] diff --git a/packages/db-postgres/test/connection.test.ts b/packages/db-postgres/test/connection.test.ts new file mode 100644 index 00000000..4df73b3f --- /dev/null +++ b/packages/db-postgres/test/connection.test.ts @@ -0,0 +1,57 @@ +import type { DatabaseInitArgs } from '@easy-cms/core' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// What the adapter asks postgres.js for; no server is needed. +const { client } = vi.hoisted(() => ({ client: vi.fn() })) +vi.mock('postgres', () => ({ default: client.mockReturnValue({ end: async () => {} }) })) +vi.mock('@easy-cms/drizzle', async (original) => ({ + ...(await original()), + connectDatabase: async () => ({}), +})) +vi.mock('drizzle-orm/postgres-js', () => ({ drizzle: () => ({}) })) + +const { postgres } = await import('../src/index.js') + +async function optionsFor(options: Parameters[0]) { + client.mockClear() + await postgres(options).init({ cwd: '.' } as DatabaseInitArgs) + return client.mock.calls[0]?.[1] +} + +describe('postgres.js options', () => { + afterEach(() => vi.unstubAllEnvs()) + + it('leaves what is not set to the URL and the PG* variables', async () => { + vi.stubEnv('PGAPPNAME', '') + const options = await optionsFor({ url: 'postgres://db/cms?prepare=false' }) + expect(options).toMatchObject({ max: 10, connection: { application_name: 'easy-cms' } }) + for (const key of ['prepare', 'idle_timeout', 'connect_timeout']) + expect(options).not.toHaveProperty(key) + expect(options.connection).not.toHaveProperty('statement_timeout') + }) + + it('passes the pool options, timeouts in seconds', async () => { + const options = await optionsFor({ + url: 'postgres://db/cms', + max: 4, + prepare: false, + statementTimeoutMs: 15_000, + idleTimeoutMs: 20_000, + connectTimeoutMs: 2_500, + applicationName: 'blog', + }) + expect(options).toMatchObject({ + max: 4, + prepare: false, + idle_timeout: 20, + connect_timeout: 2.5, + connection: { application_name: 'blog', statement_timeout: 15_000 }, + }) + }) + + it('names the connections after PGAPPNAME when it is set', async () => { + vi.stubEnv('PGAPPNAME', 'cms-worker') + const options = await optionsFor({ url: 'postgres://db/cms' }) + expect(options.connection.application_name).toBe('cms-worker') + }) +}) diff --git a/packages/integration/test/helpers.ts b/packages/integration/test/helpers.ts index da13de96..82cab505 100644 --- a/packages/integration/test/helpers.ts +++ b/packages/integration/test/helpers.ts @@ -8,7 +8,7 @@ import { type SchemaMode, silentLogger, } from '@easy-cms/core' -import { postgres } from '@easy-cms/db-postgres' +import { type PostgresAdapterOptions, postgres } from '@easy-cms/db-postgres' import { sqlite } from '@easy-cms/db-sqlite' import { PGlite } from '@electric-sql/pglite' import { createClient } from '@libsql/client' @@ -81,8 +81,11 @@ afterAll(async () => { } }) -/** The database adapter under test. Each project directory gets isolated tables. */ -export function db(): DatabaseAdapter { +/** + * The database adapter under test. Each project directory gets isolated tables. `server` options + * apply to a Postgres server only. + */ +export function db(server: Partial = {}): DatabaseAdapter { return { name: `test-${DIALECT}`, init(args) { @@ -92,7 +95,14 @@ export function db(): DatabaseAdapter { pglite ??= new PGlite() return postgres({ pglite, tablePrefix }).init(args) } - return postgres({ url: process.env.POSTGRES_URL as string, max: 3, tablePrefix }).init(args) + return postgres({ + url: process.env.POSTGRES_URL as string, + max: 3, + // CI also runs the suites through PgBouncer in transaction mode, without prepared statements. + prepare: process.env.POSTGRES_PREPARE !== 'false', + ...server, + tablePrefix, + }).init(args) }, } } diff --git a/packages/integration/test/postgres-pool.test.ts b/packages/integration/test/postgres-pool.test.ts new file mode 100644 index 00000000..e8094385 --- /dev/null +++ b/packages/integration/test/postgres-pool.test.ts @@ -0,0 +1,27 @@ +import { defineConfig } from '@easy-cms/core' +import { describe, expect, it } from 'vitest' +import { DIALECT, db, open, rawQuery, SECRET, tempProject } from './helpers.js' + +// Through PgBouncer, pg_stat_activity shows the pooler's server connections instead. +describe.runIf(DIALECT === 'postgres' && process.env.POSTGRES_PREPARE !== 'false')( + 'Postgres connections', + () => { + it('are named after applicationName', async () => { + const name = `easy-cms-test-${process.pid}` + const config = defineConfig({ + secret: SECRET, + db: db({ applicationName: name }), + collections: [{ slug: 'notes', fields: [{ name: 'text', type: 'text' }] }], + }) + const cwd = tempProject() + const cms = await open(config, cwd) + await cms.find('notes') + const rows = await rawQuery( + cwd, + `select count(*)::int as n from pg_stat_activity where application_name = '${name}'`, + ) + expect(rows[0]?.n).toBeGreaterThan(0) + await cms.destroy() + }) + }, +) diff --git a/website/guide/databases.md b/website/guide/databases.md index 8d1065c5..2c98ae55 100644 --- a/website/guide/databases.md +++ b/website/guide/databases.md @@ -49,6 +49,44 @@ PGlite is handy locally and in tests: real Postgres with nothing to install. A c db: process.env.DATABASE_URL ? postgres({ url: process.env.DATABASE_URL }) : postgres({ pglite: '.pglite' }), ``` +### Connections + +| Option | Default | | +|---|---|---| +| `max` | `10` | Connections in the pool, per instance | +| `prepare` | `true` | Prepared statements. Turn off behind a pooler that can't keep them | +| `statementTimeoutMs` | — | Cancels a statement that runs longer. Not behind PgBouncer (see below) | +| `idleTimeoutMs` | — | Closes connections left idle this long | +| `connectTimeoutMs` | `30000` | Gives up opening a connection after this long | +| `applicationName` | `easy-cms` | Shown in `pg_stat_activity`. `PGAPPNAME` also works | + +Options in the URL (`?prepare=false`) and the `PG*` environment variables still apply when +the option isn't set. `statementTimeoutMs` has no default because migrations and backups can +take a while: `easy-cms migrate` and `easy-cms backup` read the same config, so leave them room. + +**How many connections.** Every instance opens up to `max`, so the total is instances × +`max`. Keep it under 10–20% of the server's `max_connections` (`SHOW max_connections`), leaving +room for your app, migrations and admin tools. + +**Serverless and poolers.** Each serverless function instance has its own pool, and a burst of +traffic starts many of them: connect through a pooler (PgBouncer, or your host's: Neon, +Supabase, RDS Proxy) and keep `max` small, 1–3. + +```ts +db: postgres({ url: process.env.DATABASE_POOLER_URL, max: 2, prepare: false }) +``` + +- **Transaction mode** gives each transaction any server connection. Easy CMS keeps nothing + between transactions, so it works, tested in CI against PgBouncer. +- **`prepare: false`** unless the pooler keeps prepared statements across server connections: + PgBouncer does from 1.24 (`max_prepared_statements`, default 200). Otherwise queries fail + with "prepared statement … does not exist". +- **Settings on the role**, not the connection: PgBouncer refuses startup parameters such as + `statement_timeout`. Use `ALTER ROLE cms SET statement_timeout = '15s'` instead of + `statementTimeoutMs`. +- **Migrations** run through the pooler too, or over a direct connection if your host offers + one. + ## Common options | Option | Default | | diff --git a/website/th/guide/databases.md b/website/th/guide/databases.md index e8d1d56e..9da144a0 100644 --- a/website/th/guide/databases.md +++ b/website/th/guide/databases.md @@ -48,6 +48,38 @@ PGlite สะดวกสำหรับใช้งานในเครื่ db: process.env.DATABASE_URL ? postgres({ url: process.env.DATABASE_URL }) : postgres({ pglite: '.pglite' }), ``` +### Connection {#connections} + +| ตัวเลือก | ค่าเริ่มต้น | | +|---|---|---| +| `max` | `10` | จำนวน connection ใน pool ต่อ instance | +| `prepare` | `true` | prepared statement ปิดเมื่ออยู่หลัง pooler ที่เก็บไว้ไม่ได้ | +| `statementTimeoutMs` | — | ยกเลิก statement ที่รันนานเกินนี้ ใช้หลัง PgBouncer ไม่ได้ (ดูข้างล่าง) | +| `idleTimeoutMs` | — | ปิด connection ที่ว่างนานเท่านี้ | +| `connectTimeoutMs` | `30000` | เลิกรอเปิด connection หลังจากนี้ | +| `applicationName` | `easy-cms` | แสดงใน `pg_stat_activity` ใช้ `PGAPPNAME` ได้ด้วย | + +ตัวเลือกใน URL (`?prepare=false`) และตัวแปร `PG*` ยังใช้ได้ถ้าไม่ได้ตั้งตัวเลือกนั้น `statementTimeoutMs` +ไม่มีค่าเริ่มต้น เพราะ migration และ backup อาจใช้เวลานาน และ `easy-cms migrate` กับ `easy-cms backup` อ่าน config เดียวกัน จึงควรเผื่อเวลาไว้ + +**จำนวน connection** ทุก instance เปิดได้ถึง `max` รวมทั้งหมดจึงเป็นจำนวน instance × `max` ควรให้ไม่เกิน 10–20% +ของ `max_connections` ของ server (`SHOW max_connections`) เผื่อไว้ให้แอปของคุณ migration และเครื่องมือของแอดมิน + +**Serverless และ pooler** function แต่ละตัวบน serverless มี pool ของตัวเอง และ traffic ที่พุ่งขึ้นทีเดียวจะเปิดหลายตัวพร้อมกัน +ให้ต่อผ่าน pooler (PgBouncer หรือของผู้ให้บริการ เช่น Neon, Supabase, RDS Proxy) และตั้ง `max` น้อย ๆ 1–3 + +```ts +db: postgres({ url: process.env.DATABASE_POOLER_URL, max: 2, prepare: false }) +``` + +- **Transaction mode** ให้แต่ละ transaction ได้ server connection ตัวไหนก็ได้ Easy CMS ไม่เก็บอะไรค้างไว้ระหว่าง + transaction จึงใช้ได้ และ CI ทดสอบกับ PgBouncer +- **`prepare: false`** ยกเว้น pooler เก็บ prepared statement ข้าม server connection ได้ ซึ่ง PgBouncer ทำได้ตั้งแต่ 1.24 + (`max_prepared_statements` ค่าเริ่มต้น 200) ไม่อย่างนั้น query จะล้มด้วย "prepared statement … does not exist" +- **ตั้งค่าที่ role** ไม่ใช่ที่ connection เพราะ PgBouncer ปฏิเสธ startup parameter อย่าง `statement_timeout` + ใช้ `ALTER ROLE cms SET statement_timeout = '15s'` แทน `statementTimeoutMs` +- **Migration** รันผ่าน pooler ได้ หรือต่อตรงก็ได้ถ้าผู้ให้บริการมีให้ + ## ตัวเลือกทั่วไป {#common-options} | ตัวเลือก | ค่าเริ่มต้น | |