PostgreSQL driver (libpq). Either require("lunet.postgres") a prebuilt
module straight from a release archive (lunet/postgres.so; see the archive
layout in docs/RELEASE-LAYOUT.md), or build it
yourself with xmake build lunet-postgres; output is lunet/postgres.so.
Placeholders are $1, $2, … Parameters are bound natively via
PQexecParams, so values are never concatenated into SQL. There is
deliberately no db.escape.
Client library floor: libpq 17 or newer. The driver is built against libpq and will not compile (or load) against libpq 16 or older; a host with an older client library fails at build time with a named error rather than silently producing an unusable module. Install libpq 17+ (see RELEASE-LAYOUT.md).
The floor is on the libpq client library at build/load time, not on the PostgreSQL server version: a libpq 17+ client talks to a pg16 server (for example Scaleway serverless Postgres) without issue — only the client library present when the module is built and loaded must be 17 or newer.
| Function | Notes |
|---|---|
open(config) |
host, port, user, password, database, optional connect_timeout, sslmode, pool_size (default 1) |
close(conn) |
|
query(conn, sql, ...) |
returns array of row tables |
exec(conn, sql, ...) |
returns { affected_rows, last_insert_id } |
query_params / exec_params |
same behaviour as the above |
prepare(conn, sql) |
reusable prepared statement; stmt:query / stmt:exec / stmt:close; requires a single connection (pool_size = 1 or a checked-out member) — see Prepared statements |
checkout(pool) |
check one member out of the pool exclusively: returns a connection handle bound to a single server session, removed from the round-robin rotation until released. The mechanism for transaction stickiness. |
release(conn) |
return a checked-out member to the rotation. Idempotent after pool close. |
open accepts a params table with the settings below. Everything except
host and friends is optional at runtime; wrong types and unknown values
raise a loud error at open time rather than being silently ignored.
| Param | Type | Semantics |
|---|---|---|
host |
string | default "localhost" |
port |
integer | default 5432 |
user / password / database |
string | defaults "" |
connect_timeout |
integer | seconds libpq waits before the connect attempt fails; unset leaves the libpq default untouched |
sslmode |
string | one of "disable", "allow", "prefer", "require", "verify-ca", "verify-full"; unset leaves the libpq default untouched |
pool_size |
integer | default 1; see below |
Passthrough semantics: connect_timeout and sslmode are applied before the
handle returns — they go into the libpq conninfo string the driver assembles
and are in force for every connection in the pool, including the first one you
touch. The driver never invents a value the vendor already defaults: omit the
field and libpq behaves exactly as libpq documents.
There is no post-open pragma surface on this handle — no SET escape hatch,
no per-session knob. Open-time settings are the entire contract. (The
sqlite3-style read/write split deliberately does not apply to remote drivers:
the server routes read/write traffic itself.)
open accepts pool_size (default 1): how many server connections sit
behind the returned handle. One statement → one member, picked round-robin
at dispatch; close closes all members. Postgres genuinely serves
concurrent client connections, so a wider pool parallelises real work —
this is a feature here, not an escape hatch.
Transaction stickiness. A transaction belongs to the connection, so
BEGIN/COMMIT must land on the same session. A multi-member pool
violates that by construction — BEGIN and COMMIT can round-robin
onto different sessions and commit nothing. Two ways to stay safe:
- open with the default
pool_size = 1and pass that handle topostgres_tx, as before; - open with
pool_size = Nandcheckouta member for the whole transaction, hand that member topostgres_tx.transaction, thenreleaseit when the transaction is done (or let its GC release it).postgres_txitself needs no change — it already holds whatever connection it is given.
checkout(pool) returns a connection handle bound to a single server
session, removed from the pool's round-robin rotation for as long as
it is checked out. Every call on that handle (query, exec,
query_params, exec_params, prepare) targets that session, so a
BEGIN … COMMIT sequence stays on one connection even when the pool
has many members.
local pg = require("lunet.postgres")
local tx = require("lunet.postgres_tx")
local pool = pg.open({ host = "...", pool_size = 4 })
local conn = pg.checkout(pool) -- a dedicated session
local ok, err = tx.transaction(conn, function(t)
t.exec("DELETE FROM nodes WHERE path = $1", dst)
local r = t.exec("UPDATE nodes SET path = $1 WHERE path = $2 AND version = $3",
dst, src, version)
if r.affected_rows == 0 then return nil, "version conflict" end
return true
end)
pg.release(conn) -- back in the rotationRules:
- One member, one owner. A checked-out member is taken out of the rotation; the remaining members keep serving autocommit traffic.
- Exhaustion is loud, not a wait. When every member is checked out,
the next
checkoutreturns(nil, err)— the driver does not queue or yield. Backpressure is app policy: sleep and retry, or size the pool. - Release is idempotent after close.
releaseon a handle whose pool is already closed returnsnil. A double release on a live handle fails loudly. - A dropped checkout releases itself. If a checked-out handle is
garbage-collected without an explicit
release, its member goes back into the rotation at GC time. The member is never closed by that path — the pool owns its members and closes them at pool close. - Statements bind to the member they were prepared on.
prepareon a checked-out conn works (it targets that session);prepareon a pool handle still requires a single member.
Use lunet.postgres_tx (postgres_tx.lua, shipped next to postgres.so):
local pg = require("lunet.postgres")
local tx = require("lunet.postgres_tx")
local ok, err = tx.transaction(conn, function(t)
t.exec("DELETE FROM nodes WHERE path = $1", dst)
local r = t.exec("UPDATE nodes SET path=$1 WHERE path=$2 AND version=$3",
dst, src, version)
if r.affected_rows == 0 then return nil, "version conflict" end
return true
end)
if not ok and err.poisoned then pg.close(conn) endReturn non-nil to commit; return nil, reason or raise to roll back. See the
header comment in postgres_tx.lua for the full contract.
The rule that matters: a transaction belongs to the connection, not the
coroutine. A pool that checks a connection out per call and returns it
afterwards will scatter BEGIN, the work, and COMMIT across three different
sessions and commit nothing. transaction() holds one connection for the whole
unit, which is why it takes a conn rather than a config.
- No parameters → the driver uses
PQexec, which accepts multiple;-separated commands but returns only the last one's results. With parameters it usesPQexecParams, which rejects multi-command SQL outright. - After any statement error, Postgres puts the session in an aborted state where
everything fails until
ROLLBACK. The wrapper unwinds straight toROLLBACK, so the connection recovers; don't catch at.*error and keep issuing statements.
prepare exposes libpq's explicit prepared-statement protocol: the SQL is
prepared once (PQprepare) and each call executes it (PQexecPrepared) —
no per-call parse + plan on the server.
local stmt, err = pg.prepare(conn, "SELECT name FROM users WHERE age > $1")
if not stmt then error(err) end
local rows, qerr = stmt:query(30) -- same binding semantics as query
stmt:close() -- PQclosePrepared, one worker hopstmt:query(...)/stmt:exec(...)bind the varargs per call, exactly likequery/exec; each call is onePQexecPreparedon the statement's connection.- One statement is bound to one connection. Prepared-statement names
are session-scoped in libpq, so
prepareon a pool handle requirespool_size = 1(the default) and fails loudly otherwise. A checked-out member is itself a single connection, soprepareworks on it too — the statement binds to that session. Same doctrine aspostgres_tx, which now takes a checked-out member for multi-member pools. - If the connection dies, the statement dies with the session: use fails
loudly (
statement is closed/connection is closed). - Busy discipline: one statement, one in-flight call. A second
concurrent call on the same statement fails with
statement is busyinstead of interleaving on the connection. stmt:close()releases the server-side definition (one worker hop); after it, use fails loudly. A statement dropped withoutcloseleaves its server-side definition allocated until the session ends — the finalizer never sends network traffic, so callclose.- Closing the connection severs every statement still open on it.
Is it worth it? For a trivial statement the per-call server plan is unmeasurable; for a complex or high-rate one it can matter. Read the PostgreSQL documentation on the extended query protocol and decide for your own workload — there is no magic.
Verified by hand against a real server, not mocks. If you change
postgres_tx.lua or the transaction handling in postgres.c, redo this — it is
cheap and it catches the things unit tests cannot.
Start a throwaway server (no volume mounts, so this works under Colima on aarch64):
docker run -d --name lunet-tx-pg -e POSTGRES_PASSWORD=lunetpw \
-e POSTGRES_DB=lunet_tx -p 15432:5432 postgres:17Then drive it from a scratch script in .tmp/ (gitignored — deliberately not a
committed test, since nothing in CI should need a database to boot).
The methodology, which is the part worth keeping:
- Open two connections: a
workerand anobserver. Count rows only via theobserver. A single connection cannot tell you whether something was really committed, because it can see its own uncommitted work — this is what separates a real check from a self-confirming one. - First prove the trap exists. Open a transaction on connection A, do the
DELETEon connection B,ROLLBACKon A, and confirm via the observer that the delete persisted. If that assertion ever starts failing, the trap this module exists to prevent has changed shape. - Then prove the wrapper closes it: commit path visible to the observer; abort-by-nil and raise both leaving the observer's view untouched.
- Check isolation mid-transaction. Query the observer from inside
fnand confirm it cannot see the uncommitted change. - Exercise recovery. Force a statement error inside
fn, confirmerr.poisoned == false, then run a query on the worker to prove the connection survived Postgres's aborted-session state. - Exercise the poison path by closing a connection and calling
transaction()on it:BEGINfails anderr.poisonedmust betrue.
Last run: 19/19 checks passed against postgres:17 (2026-07-25).