perf(db): use the pgx driver for postgres - #3721
Draft
tink-bot wants to merge 6 commits into
Draft
Conversation
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.
tink-bot
temporarily deployed
to
preview-trusted
September 3, 2026 21:08 — with
GitHub Actions
Inactive
Preview DeploymentPreview deployments for this PR are available at:
The preview environment will start automatically on first visit. Subsequent pushes to this PR will update the Run locally with Dockerdocker pull ghcr.io/go-vikunja/vikunja:pr-3721
docker run -p 3456:3456 ghcr.io/go-vikunja/vikunja:pr-3721Last 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.
tink-bot
temporarily deployed
to
preview-trusted
September 3, 2026 21:21 — with
GitHub Actions
Inactive
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.
tink-bot
temporarily deployed
to
preview-trusted
September 3, 2026 21:55 — with
GitHub Actions
Inactive
…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.
tink-bot
temporarily deployed
to
preview-trusted
September 3, 2026 22:07 — with
GitHub Actions
Inactive
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.
tink-bot
temporarily deployed
to
preview-trusted
September 3, 2026 22:22 — with
GitHub Actions
Inactive
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_statementis 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 withThe right-hand side of the ||| operator must be a text or text array value.initPostgresEngineprobes for the extension and reconnects with pgx'sexecmode (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_statementsis 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_statementson the pooler, or point Vikunja at PostgreSQL directly — it already maintains its own pool viadatabase.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: 1inprojects.yml,external_id: 14/15inteams.yml,token_last_eight: 12345678inapi_tokens.yml. Every fixture key was cross-checked against the Go field type it maps to; these were the only mismatches.How to verify
database.schemaset to a non-public schema, and confirm the tables are created in that schema.landingagainst a task calledlandingpages) and for a title with one typo. Expected: both still match, as they did before this PR.