Single-event-loop PostgreSQL connection pooling for MoonBit.
Package path: moonbit-community/postgres/pgpool.
The package supports native and classic wasm. WasmGC is intentionally
excluded because pooled clients require the socket/TLS transport.
Each physical @client.Client is single-flight. The pool supplies concurrency
by checking out different sessions, while preserving FIFO execution within one
callback-scoped Session.
For ordinary fully consumed operations, call the pool directly:
///|
async fn _pool_quick_start(
host : String,
user : String,
database : String,
password : String,
) -> Unit {
@async.with_task_group(group => {
let config = @pgpool.Config::Config(
[@pgpool.ConnectionTarget(host~)],
user~,
dbname=database,
password~,
application_name="my-service",
pool=PoolConfig(4),
)
let (pool, executor) = @pgpool.Pool::create(config)
let task = group.spawn(no_wait=true, () => executor.run())
pool.ready()
let value : Int = pool
.query_one("select 1::int4 as value")
.get_name("value")
ignore(value)
pool.close()
task.wait()
})
}Pool-level operations automatically checkout, use, and return a connection:
query_all,query_one,query_opt, andquery_typed_allexecuteandbatch_executecheck_connectionwith_transaction
There is no manual get / release / detach_raw API.
Use Pool::with_session when several operations must use the same PostgreSQL
session:
///|
async fn _session_example(pool : @pgpool.Pool) -> Int {
pool.with_session(session => {
session.batch_execute("set application_name = 'pool-example'")
session.query_one("select 42::int4 as value").get_name("value")
})
}Session replaces the former pooled Client name. A session is valid only
during its callback. Captured handles used afterwards raise
PoolError::ScopeExpired.
Calls submitted through one Session execute FIFO. A prepared-statement
callback may use the same Session for other SQL or nested with_prepared
calls. Transaction, streaming/COPY, and cancellable callbacks keep an exclusive
session operation until their protocol work finishes, so use the capability
passed to those callbacks.
Pool and session transaction helpers reserve one physical connection for the callback:
///|
async fn _pool_transaction(pool : @pgpool.Pool) -> Unit {
pool.with_transaction(tx => {
ignore(tx.execute("update accounts set active = true"))
()
})
}Pool transaction options use the client isolation enum re-exported as
@pgpool.IsolationLevel; existing enum constructor calls remain valid. The
isolation level is selected from four fixed SQL spellings:
///|
async fn _pool_serializable_transaction(pool : @pgpool.Pool) -> Unit {
pool.with_transaction(
_tx => (),
options=@pgpool.TransactionOptions(
isolation_level=@pgpool.IsolationLevel::serializable(),
),
)
}On normal callback return, the helper commits only if the transaction is still
open; an explicit commit or rollback prevents a second completion. If the
callback raises or is cancelled, it rolls back best-effort when the transaction
is still open. Nested transactions use savepoints. Rollback cleanup is
protected from cancellation, including the wait for in-flight operations. A
captured Transaction is expired when its callback completes.
with_savepoint(name, ...) quotes name as a case-sensitive PostgreSQL
identifier. Spaces, double quotes, semicolons, and Unicode are supported.
Empty names and names containing NUL raise the client's
InvalidSavepointName error before SQL is sent; the parent remains usable.
Cancellation received while SQL is waiting for a response propagates after
protected protocol cleanup and before automatic commit. This includes nested
savepoints. If cancellation arrives during BEGIN or SAVEPOINT, successful
creation is rolled back before releasing the session or parent scope. A
COMMIT already sent still completes according to PostgreSQL's response.
Low-level protocol handles are callback-scoped so the pool can restore the connection before reuse:
Session::with_streamandwith_typed_streamreceive@client.RowStreamSession::with_simple_queryreceives@client.SimpleQueryStreamSession::with_copy_inreceives@client.CopyInSinkSession::with_copy_outreceives@client.CopyOutStream
The callbacks may consume, finish, abort, or detach their handles. They remain cancellable while waiting for application work. On callback exit, the pool drains streams or aborts unfinished COPY input before recycling the connection; task cancellation may therefore wait for an in-flight database response. There are no duplicate pgpool stream, portal, or COPY wrapper types.
An aborted connection's detached drain saves ClientError::Closed, which an
explicit finish() rethrows. Expected closure does not fail other connections
in the same task group; unexpected protocol errors still propagate.
Session::with_prepared and with_prepared_typed provide a scoped
PreparedStatement. It supports fully consumed query/execute helpers and
with_stream; explicit bind, pooled portals, and manual close are not exposed.
Inspect inferred parameter and result metadata through its read-only
params() and columns() views. Use to_owned() for an editable copy.
The callback remains cancellable and does not hold the Session operation lock.
On exit, the scope rejects new calls, waits for calls already started, then
closes a temporary or evicted statement. Transaction-scoped prepared callbacks
remain exclusive to their transaction.
Ordinary query, execute, and streaming helpers use prepared statements cached
automatically per physical connection. Configure the LRU capacity through
PoolConfig::PoolConfig:
///|
fn _pool_config() -> @pgpool.PoolConfig raise {
PoolConfig(
8,
statement_cache_capacity=100,
timeouts=Timeouts(wait_ms=Some(500)),
queue_mode=@pgpool.QueueMode::fifo(),
recycling_method=@pgpool.RecyclingMethod::verified(),
)
}The default capacity is 100. Set it to 0 to disable caching; negative
values raise PoolError::InvalidConfig. Cache entries are local to one
physical connection. Typed parameter arrays are copied when inserted, so later
caller mutation cannot rewrite a cache key. There are no public cache-manager
or cache-handle APIs. Clean recycling preserves prepared statements and their
cached plans, keeping the connection-local cache reusable across checkouts.
Invalid-statement SQLSTATE 26000 and unsupported-plan SQLSTATE 0A000
invalidate the affected entry and propagate the original database error. The
pool does not retry automatically inside a transaction.
Use Session::run_cancellable when cancellation must be tied to one pooled
operation:
///|
async fn _cancellable(pool : @pgpool.Pool) -> Unit {
@async.with_task_group(group => {
ignore(
pool.with_session(session => {
session.run_cancellable((op, token) => {
group.spawn_bg(no_wait=true, () => {
@async.sleep(50)
token.cancel()
})
let value : Int = op
.query_one("select pg_sleep(5), 1::int4 as value")
.get_name("value")
ignore(value)
})
}),
) catch {
_ => ()
}
})
}OperationCancelToken is best-effort and becomes inert when its callback
ends, preventing a stale token from cancelling work performed by a later
borrower.
The run_cancellable callback remains cancellable while waiting for
application work. Before releasing its exclusive session operation, the scope
waits for in-flight requests and any cancel packet already being sent.
Queued requests keep FIFO order while waiting for a previous cancel send to finish. The next request is marked active only after that wait, so a delayed cancel cannot spill into the next request. Cancelling a waiter releases its place and any acquired operation lock.
Ordinary task cancellation waits for required protocol cleanup and rollback;
it does not automatically send a PostgreSQL cancellation request or retry SQL.
Cancellation is checked again when the outermost protected operation ends.
Explicit caller protection defers cancellation further. Use explicit abort()
on a raw client when the physical connection must be stopped immediately.
pgpool.Config takes a non-empty array of ConnectionTarget records. Each
record supplies a host, a hostaddr, or both, plus its own port (default
5432). If both addresses are present, the client connects to hostaddr and
uses host for TLS hostname verification. The order of records is the order
of connection attempts unless host load balancing is enabled.
Config::Config validates every address and port, the user and database names,
and TLS settings before the pool is created. A target needs at least one
non-empty address, and its port must be in 1..65535. Errors identify the
zero-based target index. The constructor copies the supplied array; targets()
returns a read-only view of that snapshot. Common authentication, TLS, startup,
TargetSessionAttrs, host load balancing, and PoolConfig options stay on the
outer config.
The Debug output for a config shows <hidden> in place of a configured
password; a missing password remains None.
PoolConfig controls:
max_size- checkout/create/recycle timeouts
- FIFO or LIFO idle-connection selection
- fast, verified, clean, or custom recycling
- per-connection statement-cache capacity
- shared asynchronous-message capacity (256 by default)
Read the current timeout settings from pool.config().timeouts.
The default Fast mode sends no cleanup SQL when an idle connection is
checked out. On every Session return, the pool checks the client's latest
ReadyForQuery status. A connection still in an active (T) or failed (E)
transaction is discarded, along with its statement cache, instead of being
offered to the next borrower. The pool does not issue an automatic ROLLBACK.
Idle connections retain session state such as SET values and LISTEN
subscriptions under Fast; select Clean when that state must be cleared.
PoolOptions provides post_create, pre_recycle, and post_recycle hooks.
Hooks receive an Operation that expires when the callback ends. Queries and
commands issued through it finish before the next borrower uses the connection.
Hooks must leave the connection transaction idle. A post_create hook that
leaves a transaction open raises PoolError::HookLeftOpenTransaction and
discards the connection; recycle hooks discard the connection and retry with a
new one. Custom Connector remains a low-level entry that returns a raw Client.
Pool::next_message() reads notices, notifications, and parameter changes
from one shared bounded buffer. Each PoolAsyncMessage includes a unique
physical connection_id and the message. Use one consumer. When the buffer
fills, the oldest message is discarded and
Pool::dropped_async_messages() increases. PoolConfig::PoolConfig accepts a
positive async_message_capacity; the default is 256. After Pool::close(),
buffered messages can still be read, then next_message() returns None.
An idle listening connection delivers notifications without another checkout.
Create and recycle deadlines are hard for client I/O: when either expires, the
pool first calls Client::abort() on the candidate physical connection and
then reports PoolError::Timeout(Create) or retires the timed-out recycle
candidate. Custom connector code before it returns a Client, and hook code
that does not perform client I/O, must still cooperate with ordinary task
cancellation.
If checkout is cancelled during recycling, including either recycle hook, the pool discards that connection and its statement cache and restores the reserved capacity slot. A later checkout can open a replacement connection.
A custom Connector receives a @client.Config and returns a
(@client.Client, @client.ClientExecutor) pair. The pool executor starts the
physical executor and checkout waits for client.ready().
Pool::create() does not open a socket. Spawn the single-use PoolExecutor::run()
and await pool.ready() before use. A repeated run() raises
PoolError::ExecutorAlreadyStarted. Pool::close() rejects new operations and
closes idle connections. Active callbacks are allowed to finish; their physical
connections close instead of returning to the idle set. Await the executor task
to know that all connections have stopped. Pool executor cancellation or an
unexpected pool maintenance failure closes the whole pool. An individual
physical connection failure closes that client; the pool discards it on return
or on its next checkout and can create a replacement.
Pool::resize() never interrupts checked-out sessions. When shrinking cannot
remove enough currently available capacity tokens, the pool records resize
debt; later failed checkouts and session returns repay that debt before making
tokens available. Expanding first cancels debt and adds only the remaining
capacity, preventing shrink/expand races from exceeding the configured maximum.
PoolError is limited to pool lifecycle:
ClosedExecutorAlreadyStartedTimeoutScopeExpiredInvalidConfig
Database, decoding, and row-count errors are propagated as
@client.ClientError.