Skip to content

perf(db): use the pgx driver for postgres - #3721

Draft
tink-bot wants to merge 6 commits into
mainfrom
perf/migrate-pgx
Draft

perf(db): use the pgx driver for postgres#3721
tink-bot wants to merge 6 commits into
mainfrom
perf/migrate-pgx

Conversation

@tink-bot

@tink-bot tink-bot commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

lib/pq sends every query as unnamed prepared statement, so PostgreSQL replans each of ~12.6 statements per request from scratch. pgx keeps per-connection statement cache and reuses named prepared statements instead.

xorm already ships "pgx" driver mapping, so switch is driver name plus blank import. Connection string format unchanged — pgconn parses same URL, forwards unknown params (search_path) as runtime params.

lib/pq was used only for QuoteIdentifier, now inlined.

No new config. cache_statement is pgx's default and the only mode that reuses named prepared statements — every other mode still replans per execution, so an escape-hatch key would only ever mean "undo this PR".

ParadeDB keeps unnamed statements

ParadeDB's ||| operator needs the search term while the statement is planned. Named prepared statements are planned at Parse, term still an unbound parameter, so every search fails with The right-hand side of the ||| operator must be a text or text array value.

initPostgresEngine probes for the extension and reconnects with pgx's exec mode (unnamed statements, planned at Bind) when it is present. ParadeDB installations keep working search and give up the statement cache; every other PostgreSQL setup keeps it. No config key, no search behaviour change.

Upstream fixed the identical failure for === in paradedb/paradedb#5907 (v0.25.2) but not for |||, still absent as of v0.25.6. The workaround can go once that lands.

Breaking change risk

A connection pooler in transaction pooling mode cannot share named prepared statements unless configured for it. PgBouncer supports them from 1.21, but only when max_prepared_statements is above 0, and the default is 0. Setups behind such a pooler will fail loudly on startup after upgrading.

Workaround for those setups, no Vikunja config needed: raise max_prepared_statements on the pooler, or point Vikunja at PostgreSQL directly — it already maintains its own pool via database.maxopenconnections (default 100).

Fixture fixes

pgx binds parameters using the type the statement description reports, where lib/pq sent text and let PostgreSQL coerce. Three fixture values relied on that coercion and are now correctly typed: is_archived: 1 in projects.yml, external_id: 14/15 in teams.yml, token_last_eight: 12345678 in api_tokens.yml. Every fixture key was cross-checked against the Go field type it maps to; these were the only mismatches.

How to verify

  1. Point a Vikunja instance at PostgreSQL and start it, so migrations run against an empty database.
  2. Log in, create a project, create and complete a task, and run a search.
  3. Expected: everything behaves as before; the log contains no driver or type-conversion errors.
  4. Repeat with database.schema set to a non-public schema, and confirm the tables are created in that schema.
  5. On a ParadeDB instance, search for a prefix of a task title (for example landing against a task called landingpages) and for a title with one typo. Expected: both still match, as they did before this PR.

lib/pq sends every query as an unnamed prepared statement, so PostgreSQL
plans each statement from scratch. pgx via database/sql keeps a per-connection
statement cache and reuses named prepared statements instead, removing that
planning cost from the hot path.

xorm already ships a "pgx" driver mapping, so the switch is just the driver
name plus the blank import. The connection string format is unchanged: pgconn
parses the same URL and forwards unknown parameters (search_path) as runtime
parameters.

Adds database.queryexecmode as an escape hatch. Named prepared statements do
not work through a connection pooler in transaction pooling mode unless it
supports them (PgBouncer 1.21+), so those setups can set it to "exec" to get
the previous behaviour.

lib/pq was only used for QuoteIdentifier, which is now inlined.
@github-actions github-actions Bot added area/config config.yml, env vars, runtime flags, deployment config area/database Database engine behavior, schema issues, cross-engine DB bugs concern/performance Slow, laggy, or scaling issues db/postgres PostgreSQL engine-specific issue labels Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

Preview Deployment

Preview deployments for this PR are available at:

URL Tag Commit
https://pr-3721.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:pr-3721 latest
https://sha-28937f2031a554a52d5e657696e714a1e795623b.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-28937f2031a554a52d5e657696e714a1e795623b 28937f2
https://sha-87cdaeb7ef51b9b9bdf696935df4bc5092a7218a.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-87cdaeb7ef51b9b9bdf696935df4bc5092a7218a 87cdaeb
https://sha-2a1c8d73a16db876713e38eccf18070158f8e5ad.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-2a1c8d73a16db876713e38eccf18070158f8e5ad 2a1c8d7
https://sha-98fa1207f9dfa9ccc83f1ec3ac9ed181fe403f84.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-98fa1207f9dfa9ccc83f1ec3ac9ed181fe403f84 98fa120
https://sha-5d05860018b3048a302de023abf0634f8847fdcd.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-5d05860018b3048a302de023abf0634f8847fdcd 5d05860
https://sha-d0af7799304bd9d63c1dd25211034cd827361f01.preview.vikunja.dev ghcr.io/go-vikunja/vikunja:sha-d0af7799304bd9d63c1dd25211034cd827361f01 d0af779

The preview environment will start automatically on first visit. Subsequent pushes to this PR will update the pr-3721 image — the preview picks up the new version on restart. The per-commit URLs point to a specific version and will not change.

Run locally with Docker
docker pull ghcr.io/go-vikunja/vikunja:pr-3721
docker run -p 3456:3456 ghcr.io/go-vikunja/vikunja:pr-3721

Last updated for commit 28937f2

The only pgx exec mode that reuses named prepared statements is
cache_statement, which is also pgx's default. Every other mode still makes
PostgreSQL plan each execution, so the key's escape-hatch value would have
undone the whole change for anyone who set it — a permanent config key whose
only job is an off switch.

Setups behind a connection pooler in transaction pooling mode that cannot
share named prepared statements can either set max_prepared_statements above 0
on the pooler, or connect to PostgreSQL directly: Vikunja already maintains
its own pool via database.maxopenconnections.
pgx encodes parameters using the type the statement description reports, so
an integer 1 for a bool column is rejected outright:

    unable to encode 0x1 into binary format for bool (OID 16)

lib/pq sent the value as text and let PostgreSQL coerce it. Every other bool
fixture already uses true/false.
…ures

Same encoding mismatch as the is_archived fixtures: pgx binds parameters
using the type the statement description reports, so a YAML integer for a
varchar column is rejected:

    unable to encode 0xe into text format for varchar (OID 1043)

Cross-checked every fixture key against the Go field type it maps to; these
three were the only remaining mismatches.
PostgreSQL infers a parameter's type from the cast applied to it, so in
`? ::pdb.fuzzy(1, t)` the placeholder is described as pdb.fuzzy rather than
text. lib/pq left parameter types unspecified and sent the value as text, so
the type's input function ran server side. pgx binds what the statement
description reports, which ParadeDB rejects:

    The right-hand side of the `|||` operator must be a text or text array value

Casting through text first pins the parameter and leaves the ParadeDB
semantics unchanged.
ParadeDB's ||| operator needs the search term while the statement is planned.
Named prepared statements are planned at Parse, with the term still an unbound
parameter, so every search fails with:

    The right-hand side of the `|||` operator must be a text or text array value

lib/pq never hit this because unnamed prepared statements are planned at Bind,
with the parameter values in hand. Probe for the extension while connecting and
fall back to pgx's exec mode, which uses unnamed statements, when it is present.
ParadeDB installations therefore keep working search and give up the statement
cache; everyone else keeps it.

Upstream fixed the same failure for === in paradedb/paradedb#5907 (v0.25.2) but
not for |||, still absent as of v0.25.6. This can go once that lands.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config config.yml, env vars, runtime flags, deployment config area/database Database engine behavior, schema issues, cross-engine DB bugs concern/performance Slow, laggy, or scaling issues db/postgres PostgreSQL engine-specific issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants