Executors¶
rowx.postgres.connect(dsn) returns a Connection. Use it as an async context manager, or call close(). Pass a
ConnectOptions instead of a DSN to set connect_timeout, statement_cache_size, server_settings and more
(ConnectOptions.from_dsn(dsn, connect_timeout=5)).
Pools¶
rowx.postgres holds the Postgres backend (connect, ConnectOptions, Connection, Pool, Transaction). The
shared pieces (query, query_unchecked, QueryResult, RowStream, Executor and the errors) belong to
rowx_core and are re-exported by rowx. The shared pieces are the same objects as the rowx_core exports. Pool
imported from rowx is the executor protocol, not a connection pool. import rowx loads no backend. Install
rowx[postgres] for both the CLI and PostgreSQL. A runtime-only install of rowx-postgres imports the backend as
rowx_postgres and does not include the CLI.
A query captures its template without selecting a database. The connection, pool or transaction passed to a query
supplies its compiler and execution behavior. Construction rejects a bad template, including a bad :ident value,
and the compiler emits $n and the adjacency space when the method runs and raises for :ident placement, the
63-byte name limit, and the parameter cap (Errors). Static checking separately selects its backend
from the configured database URL, without importing or executing application code.
A Pool is an Executor, so every query method takes it in place of a connection. The constructor does no IO, so a
pool is safe to create at module level. async with pool warms it to min_size and closes it on exit; the first
acquire also opens it.
import asyncio
from rowx.postgres import Pool
from rowx import query
async def main() -> None:
async with Pool(DSN, min_size=1, max_size=4) as pool:
# acquire, run, release: one call
assert await query[int](t"SELECT 1").one(pool) == 1
# many tasks share the pool; waiters are served in order
results = await asyncio.gather(*(query[int](t"SELECT {i}::int").one(pool) for i in range(20)))
assert results == list(range(20))
# hold one connection for several statements
async with pool.acquire() as conn:
await query(t"CREATE TEMP TABLE scratch (n int)").execute(conn)
await query(t"INSERT INTO scratch VALUES (1), (2)").execute(conn)
assert await query[int](t'SELECT count(*) AS "n!" FROM scratch').one(conn) == 2
assert pool.size <= pool.max_size
asyncio.run(main())
Sizing and lifetimes¶
| Option | Default | Meaning |
|---|---|---|
min_size |
0 | Connections kept open (opened by open() and maintained in the background). |
max_size |
10 | The most connections open at once. Waiters queue past it. |
acquire_timeout |
30.0 | Seconds an acquire waits before PoolTimeoutError. A failed connect is retried until then. |
idle_timeout |
600.0 | Idle connections above min_size are closed after this long. |
max_lifetime |
1800.0 | A connection older than this is closed on release instead of reused. |
max_queries |
None |
Retire a connection after this many queries. |
ping_after |
30.0 | A connection idle for longer is pinged at checkout. There is no round trip otherwise. |
Idle connections are reused most-recently-used first, so hot connections stay hot and cold ones age out. A query that
fails with an IO error is not retried, because it may not be idempotent. pool.expire_connections() retires every
connection as it is released. pool.size, idle, waiting, min_size, max_size and closed are available for
metrics.
import asyncio
from rowx.postgres import Pool
from rowx import query
from rowx import PoolTimeoutError
async def main() -> None:
async with Pool(DSN, max_size=1, acquire_timeout=0.2) as pool, pool.acquire():
try:
await query[int](t"SELECT 1").one(pool)
except PoolTimeoutError:
pass
else:
raise AssertionError
asyncio.run(main())
Hooks and the reset¶
Hooks are async callables that take a Connection. They run on the caller's event loop, and a hook that raises
discards the connection.
initruns once per connection, at its first checkout.setupruns before each checkout.resetruns at release, after the reset query.
On release the pool runs reset_query (by default RESET_QUERY: RESET SESSION AUTHORIZATION; RESET ALL; SELECT
pg_catalog.pg_advisory_unlock_all(); CLOSE ALL; UNLISTEN *; DISCARD TEMP;), preceded by a ROLLBACK when the
connection may be inside a transaction. When it may not be, the pool puts a short idle check in front of the reset
(default or custom), and a reset that fails the check, because a transaction is unexpectedly open, discards the
connection. The reset resets authorization first and then the settings, so the rest of it runs as the startup role with
the startup search_path. It does not DISCARD ALL or DEALLOCATE ALL, so the statement cache survives. Pass
reset_query=None to turn it off; an open transaction is still rolled back before a reset hook runs.
reset_query=None is unsafe for a pool that serves more than one tenant or role: nothing then clears SET ROLE,
search_path, temporary tables, advisory locks or custom settings between checkouts.
RESET ALL returns session settings to their startup values. That is why server_settings survive a release (they
are part of the startup options) while a SET run in init does not: put settings you want to keep in
ConnectOptions(server_settings=...), or re-apply them in setup.
import asyncio
from rowx.postgres import ConnectOptions, Connection, Pool
from rowx import query
async def main() -> None:
async def init(conn: Connection) -> None:
await query(t"SET work_mem = '8MB'").execute(conn)
opts = ConnectOptions.from_dsn(DSN, server_settings={"statement_timeout": "5s"})
async with Pool(opts, max_size=1, init=init) as pool:
async with pool.acquire() as conn:
assert await query[str](t"SHOW work_mem").one(conn) == "8MB"
assert await query[str](t"SHOW statement_timeout").one(conn) == "5s"
async with pool.acquire() as conn:
# RESET ALL put work_mem back; the startup setting survived
assert await query[str](t"SHOW work_mem").one(conn) != "8MB"
assert await query[str](t"SHOW statement_timeout").one(conn) == "5s"
asyncio.run(main())
A fetch on a pool acquires at its first row and holds that connection until the stream ends or is closed. Prefer
async with, and see the caveats below.
Deployment caveats¶
- Fork. A pool records its process id, and using it in a forked child raises
InterfaceError. Create the pool after the fork (for gunicorn, do not create it at import time under--preload; create it in a worker start hook or the app's startup). - What the reset resets: the role and session authorization (
RESET SESSION AUTHORIZATION;RESET ALLalone doesn't cover them), settings, advisory locks, cursors,LISTENs and temporary tables (DISCARD TEMP). A cached statement that used a dropped temporary table fails on its next use, like any statement against a dropped table. A customreset_queryreplaces all of this, so keep the parts you need. - SQL-level
PREPAREis unsupported across checkouts. The reset doesn'tDEALLOCATE ALL, because that would destroy the statements rowx caches, so a statement prepared withPREPAREmay or may not survive into the next checkout. Use bound parameters instead. - Session encoding drift. rowx sends UTF-8 and standard-conforming strings, and watches the server's parameter
reports. If application SQL runs
SET client_encodingto anything butUTF8(or its aliasUNICODE) orSET standard_conforming_strings = off(alsoSET LOCAL), the connection fails every later call withInterfaceErrornaming the setting. The statement that caused it fails too, instead of returning rows that may be decoded wrongly. A pool discards the connection when it is released, so a legitimateSET standard_conforming_strings = offcosts a connection. On a pool with hooks (reset=,setup=orinit=), release itself raises after discarding it. A role or database default ofstandard_conforming_strings = offmakes the first call of every checkout fail. Settingclient_encoding = 'UTF8'again changes nothing. Leaving atransaction()block because the body raised keeps the body's exception; the failed rollback is attached as a note. This is a backstop with a small window: a statement already queued behind theSET(for example from another task sharing a connection) may still run first. - Early exit from a pooled stream drains. Breaking out of a
fetchmakes the connection discard the remaining rows before the reset runs. The connection stays counted againstmax_sizewhile it drains. Bound large reads withLIMIT. - Hooks move release onto the caller's path. With no hooks, release happens in the background and
__aexit__returns at once. With any hook set,__aexit__awaits theresethook and the reset query first. - Leases. A stream or transaction on a pooled connection is tied to that checkout. Using it after the
async with pool.acquire()block raisesInterfaceError.conn.detach()takes a connection out of the pool for good.
Coming from asyncpg¶
| asyncpg | rowx |
|---|---|
await asyncpg.create_pool(dsn, min_size=1, max_size=10) |
Pool(dsn, min_size=1, max_size=10), then async with pool or await pool.open() |
await pool.fetchrow(sql, *args) |
await query[Row](t"...").one(pool) (or .optional(pool)) |
async with pool.acquire() as conn: |
async with pool.acquire() as conn: |
async with conn.transaction(): |
async with conn.transaction() as tx: or async with pool.transaction() as tx: |
await pool.close() |
await pool.close() (terminate() is synchronous) |
Transactions¶
conn.transaction() and pool.transaction() (which holds a connection for the block) return a Transaction. It is an
Executor: queries run on it are part of the transaction. A clean exit commits, and an exception or a cancellation
rolls back. commit() and rollback() end it early, and using the transaction afterwards raises InterfaceError.
tx.transaction() opens a savepoint, which takes no isolation arguments.
import asyncio
from rowx.postgres import Pool
from rowx import query
async def main() -> None:
async with Pool(DSN, max_size=2) as pool:
count = query[int](t'SELECT count(*) AS "n!" FROM orders')
before = await count.one(pool)
# an exception rolls back
try:
async with pool.transaction(isolation="serializable") as tx:
await query(t"DELETE FROM orders").execute(tx)
raise RuntimeError
except RuntimeError:
pass
assert await count.one(pool) == before
# a savepoint rolls back on its own
async with pool.transaction() as tx:
await query(t"DELETE FROM orders WHERE id = (SELECT min(id) FROM orders)").execute(tx)
try:
async with tx.transaction():
await query(t"DELETE FROM orders").execute(tx)
raise RuntimeError
except RuntimeError:
pass
assert await count.one(tx) == before - 1
await tx.rollback()
assert await count.one(pool) == before
asyncio.run(main())
- Failed transactions. An error inside the block marks the transaction failed. Leaving the block cleanly while it
is failed raises
InterfaceErrorinstead of lettingCOMMITroll back silently. Rolling back to a savepoint clears the mark. - The ending statement can't be cancelled.
COMMITandROLLBACKrun detached, so a second cancel can't leave the transaction open. If you are cancelled while aCOMMITis in flight, the outcome is unknown: it raises and the connection is discarded. - A failed
COMMIT(for example40001) raisesDatabaseError; the connection is left idle and outside a transaction. - Raw
BEGINandCOMMITalongside the helper are unsupported. Use one or the other on a connection.
Server-side poolers and the statement cache¶
Each connection keeps an LRU cache of prepared statements, sized by statement_cache_size (default 256). 0 disables
the cache, so every call uses an unnamed prepare.
Behind an external pooler in transaction mode (PgBouncer, for example), a prepared statement may land on a different
server connection than the one that prepared it. Either connect with statement_cache_size=0, or use PgBouncer 1.21 or
newer with max_prepared_statements set.
from rowx.postgres import ConnectOptions, Pool
pool = Pool(ConnectOptions.from_dsn("postgresql://app@pooler:6432/app", statement_cache_size=0), max_size=5)
Also behind a pooler:
- Transaction mode hands out a different server connection per transaction, so session state (
SET, advisory locks,LISTEN) isn't reliable. The reset query is then pointless work, so considerreset_query=None, and keepmax_sizebelow the pooler's server pool size. Keep per-tenant state inside a transaction (SET LOCAL) there, and see Pool reset for whyreset_query=Noneis unsafe against Postgres directly. - Session mode keeps a server connection per client connection, so the default reset works as it does against Postgres directly.