Advanced Configuration
PormG is designed for Julia's asynchronous task scheduling. This section covers connection pooling, advisory locks, and the performance implications of the async-first design.
Connection Pooling & Async-First Design
- Async-First API: The synchronous
fetch()API is a wrapper around the asynchronousfetch_async()core. This wrapper yields to the Julia scheduler while waiting for the database, ensuring compatibility with async frameworks. See the Async & Concurrency guide for usage patterns. - Pooling Strategy: The default strategy is
:poll, which retries at configured intervals. Use:blockfor PostgreSQL to let the server-side manage the wait, often combined with astatement_timeout. - Sizing & capacity: A pool starts at
pool_sizeconnections (default3) and grows lazily, on demand, up topool_size × 10under concurrent load — so the idle footprint stays small while async fan-out still gets headroom. If every connection is busy and none frees within the retry/timeout budget,acquire_connectionraises a catchablePoolTimeoutError(exported by PormG). Raisepool_sizein yourconnection.ymlto add capacity for genuinely high-concurrency workloads:
dev:
adapter: PostgreSQL
database: 'formula1'
# ...
pool_size: 10 # base 10 → grows to 100 under burstA misspelled pool key (pool:, poolsize:, pool_timout:) is reported on load with a suggestion instead of silently leaving the default in place — see Unrecognised keys.
Acquire timeout (
pool_timeout): How longacquire_connectionwaits for a free connection before raisingPoolTimeoutError— default 30 s. Setpool_timeout:inconnection.yml(seconds; fractional allowed) to fail fast instead of blocking a request while the pool is saturated:dev: adapter: PostgreSQL database: 'formula1' pool_size: 10 pool_timeout: 5 # give up after 5s waiting for a connection, then raise PoolTimeoutErrorAn explicit per-call
acquire_connection(pool; timeout_seconds=…)still overrides it, andConfiguration.register_connectionaccepts the samepool_timeoutkwarg. A value≤ 0falls back to the 30 s default (a "never wait" setting is ambiguous and a footgun). Absent means the historical 30 s — zero behavior change.Connect failures (
PoolConnectError,fail_fast_on_connect):pool_timeoutcovers a saturated healthy pool. A pool that can never open a connection — a wrong password, a missing role/database, an unopenable SQLite path — is a different failure: waitingpool_timeoutfor it to "free up" is pointless, and blamingpool_sizeis misleading.acquire_connectiontherefore classifies the driver error: a permanent one (PostgreSQL auth / missing role or database; SQLiteunable to open database file) fails fast with a catchablePoolConnectError(exported) that carries the underlying driver cause and a redacted connection string — remedy: fix credentials/host/database, notpool_size. Ambiguous errors (host/DNS/network — possibly a transient blip) are not fast-failed; they wait to the deadline as before and then also surfacePoolConnectError(with the cause) rather thanPoolTimeoutError. Setfail_fast_on_connect: falseto opt out of the fast-fail and keep waiting the fullpool_timeout:dev: adapter: PostgreSQL database: 'formula1' pool_size: 10 fail_fast_on_connect: false # default true; false = wait pool_timeout even on a bad passwordDefault is
true(zero-config: a misconfigured deploy fails immediately instead of hanging every request for 30 s).register_connectionaccepts the samefail_fast_on_connectkwarg. Transient recovery is not retried inside the pool — retry the whole operation at the application layer (the same rule as lost connections inside a transaction).Idle reaping & max-lifetime (opt-in): By default the pool never shrinks after a burst and reuses connections indefinitely (a dropped connection is caught reactively by the liveness check on the next checkout). For long-lived services — or databases/proxies that drop idle connections — you can enable a background reaper via
connection.yml(both in seconds,0/absent = off):dev: adapter: PostgreSQL database: 'formula1' pool_size: 10 idle_timeout: 60 # close *overflow* connections idle > 60s, trimming back toward pool_size max_lifetime: 1800 # retire connections older than 30 min (on return, and by the sweeper)Reaping is overflow-only and never drops below the base
pool_size(those stay warm), and never closes an in-use connection. It closes the connection and clears its slot in place — the pool's slot layout is unchanged, so a reaped slot simply opens a fresh connection on next use. Disabled by default: unset means zero behavior change. Programmatic pools accept the sameidle_timeout/max_lifetimekwargs viaConfiguration.register_connection.Health snapshot (
pool_stats):pool_stats(exported) returns aNamedTuplefor debugging saturation — pass a pool object or a connection key/path:using PormG pool_stats("db") # => (; pool_size, size, in_use, available, ceiling, waiting)pool_sizeis the configured floor,sizethe slots allocated so far (== in_use + available),ceilingthe maximum (pool_size × 10), andwaitingthe callers currently parked for a connection — a non-zerowaitingwithin_use == ceilingis the signature of saturation (seePoolTimeoutError). Counts are read under the pool lock for a coherent snapshot.Leak detection (
leak_detection_threshold, opt-in): A connection acquired but never released (e.g. afetch_asyncthat's never awaited) is silently lost until the pool starves. Setleak_detection_threshold(seconds,0/absent = off) to haveacquire_connectionemit a single@warn— naming the slot and hold time — when a connection has been held past the threshold:dev: adapter: PostgreSQL database: 'formula1' pool_size: 10 leak_detection_threshold: 30 # warn when a connection is held > 30s without releaseThe scan runs on the next
acquire_connection(no background task), so a leak is flagged as the pool comes under pressure — pointing at the offending slot just before aPoolTimeoutError. Off by default;register_connectionaccepts the sameleak_detection_thresholdkwarg.Thread Safety: PormG uses
ReentrantLockfor pool management.Failed-rollback self-healing: If a transaction's
ROLLBACKitself fails (e.g. the connection died mid-transaction), the pool never returns that connection as-is. It is renewed in its slot (PostgreSQL:LibPQ.reset!; SQLite: a fresh handle, with the old one closed so it releases the database file write-lock) or — if renewal also fails — closed and its slot cleared so the next borrower opens a fresh connection.
Advisory Locks
Use advisory locks to ensure long-running tasks (migrations, seeds, imports) do not run in parallel across processes.
using PormG, LibPQ # advisory locks are PostgreSQL-only
# Wrap multiple operations in an advisory lock
PormG.run_in_transaction("db") do
with_advisory_lock(settings, "my_job_name") do
# Long-running task
M.Result.objects.create("year" => 2025, "name" => "New Race")
bulk_insert(M.Result.objects, results_df)
end
endKey Technical Details
- Hashing: Keys are hashed using MD5 to provide a 64-bit bigint identifier.
- Cleanup: PostgreSQL releases the lock automatically if the session drops.
- SQLite Limitation: SQLite does not support advisory locks; the helper is a no-op on that backend — the body runs unprotected and the wait/timeout keyword arguments are ignored. It warns once per lock key; pass
on_missing_lock = :ignoreto accept the no-op silently, oron_missing_lock = :errorto raiseBackendCapabilityErrorinstead of running unprotected. See Advisory Locks.
The Boot-Time Hazard
There is an important boot-time hazard when using @import_models in a server module.
@import_models eventually calls Models.set_models(...). If the corresponding configuration path is not loaded yet, set_models(...) can trigger Configuration.load(path) implicitly. If that happens before the server has selected the intended environment, PormG may initialize that settings object using the default environment and retain it for the rest of the process.
If set_models(...) triggers an implicit Configuration.load(path) before the server has selected its environment, PormG initializes that settings object from the default environment and keeps it for the rest of the process. Nothing errors — the application simply runs against the wrong database until it restarts. Always call Configuration.load(path; env = "...") explicitly before @import_models in server-side code.