Advisory Locks
Advisory locks are a unique feature of PostgreSQL that allows applications to define their own locking semantics. Unlike row-level or table-level locks, advisory locks have no inherent meaning to the database; they are simply a mechanism for application-level synchronization.
PormG provides high-level support for advisory locks through the with_advisory_lock function.
Why use Advisory Locks?
In a distributed environment (especially with the Async-First architecture of PormG), you might need to ensure that only one task or one process is performing a specific action at a time.
Common use cases include:
- Generating unique report files.
- Synchronizing access to external non-SQL APIs.
- Preventing race conditions in complex logic that spans multiple tables but doesn't have a single "parent" row to lock.
Usage
The with_advisory_lock function requires a connection key (pointing to a PostgreSQL database) and a key string.
# Load your models (preferred: hot-reload-friendly, self-registering)
PormG.@import_models "db/models.jl" models
import .models as M
# Example: Ensuring only one task updates a specific Driver's statistics
driver_id = 1
lock_key = "driver_update_$(driver_id)"
PormG.with_advisory_lock("db_2", lock_key; wait=true, timeout_ms=10000) do
# Critical section code here
# While inside this block, no other process using this lock_key
# can enter its own with_advisory_lock block.
driver = M.Driver.objects.filter("driverid" => driver_id) |> DataFrame
@info "Updating stats for $(driver[1, :surname])"
sleep(2) # Simulate work
endConfiguration and Strategies
Waiting Strategies
When a lock is already held by another session, you can choose how PormG should behave:
- Non-blocking (
wait=false): Immediately raisesOperationalErrorif the lock cannot be acquired. The same type is raised when a:pollor:blockacquisition exceedstimeout_ms. - Client Polling (
strategy=:poll): (Default) PormG will try to acquire the lock, wait for a few milliseconds, and try again until thetimeout_msis reached. - Server Blocking (
strategy=:block): PormG tells PostgreSQL to block the connection until the lock is granted. This is more efficient as it reduces network traffic, but it ties up a database connection from the pool.
Timeouts
The timeout_ms parameter ensures your application doesn't hang indefinitely.
- In
:pollstrategy, the timeout is managed by Julia. - In
:blockstrategy, PormG temporarily sets the PostgreSQLstatement_timeoutfor that specific acquisition.
Implementation Details
- PostgreSQL: Implementation uses
pg_try_advisory_lock(non-blocking) orpg_advisory_lock(blocking). - SQLite:
with_advisory_lockis a no-op — see the warning below. - Async Safety:
with_advisory_lockusesLibPQ.async_executeandfetch()to ensure that the Julia task yields while waiting for the database, keeping the event loop unblocked.
On a SQLite connection the body of with_advisory_lock runs with no mutual exclusion at all. wait, timeout_ms, strategy and interval_ms are accepted and ignored, and the contention path cannot fire, so code that reacts to OperationalError never sees one there.
SQLite's own writer serialization (BEGIN IMMEDIATE) is per-database-file and per-process; it is not a substitute for a named application lock, and it protects nothing for the non-SQL critical sections this page recommends locks for — report generation, external API calls, scheduled jobs. Treat SQLite as single-instance, exactly as migrations do, and rely on advisory locks only where PostgreSQL is the production backend.
Choosing what SQLite does: on_missing_lock
The no-op stays the default — it is what lets one codebase run PostgreSQL in production and SQLite in tests. But because what degrades here is a guarantee rather than a query, the SQLite path is not silent, and on_missing_lock lets you pick (#277):
on_missing_lock | On SQLite | Use it when |
|---|---|---|
:warn (default) | Body runs; warns once per key | You want to be told, but not stopped |
:ignore | Body runs, silently | You have read this page and accepted the no-op |
:error | Raises BackendCapabilityError; the body does not run | The exclusion is genuinely required for correctness |
The same call site works on both backends — that is the point of the keyword:
# Default: a real lock on PostgreSQL, a warning once per key on SQLite.
PormG.with_advisory_lock(connection_key, "driver_update_$(driver_id)") do
# rebuild this driver's standings
end
# SQLite gives no protection here and that is acceptable — stay quiet.
PormG.with_advisory_lock(connection_key, "circuit_cache_warm"; on_missing_lock = :ignore) do
# …
end
# This MUST be exclusive. Refuse to run on a backend that cannot promise it.
PormG.with_advisory_lock(connection_key, "season_points_recalc"; on_missing_lock = :error) do
# never reached on SQLite — BackendCapabilityError is raised instead
endOn PostgreSQL on_missing_lock is accepted and ignored: a real lock is always taken, so there is no missing-lock case to have a policy about. An unrecognised value raises InvalidValueError on both backends, so a typo cannot lie in wait until you happen to run on SQLite.
The warning fires once per distinct lock key, tracked in-process and independently of the logger in use, so a scheduled job taking the same lock in a loop logs one line rather than one per iteration. Tracking is capped at 64 distinct keys — a fair ceiling for a genuinely per-entity key like "driver_update_$(driver_id)" — and the warning that reaches the cap says so rather than going quiet without telling you.
API Reference
PormG.AdvisoryLock.with_advisory_lock — Function
with_advisory_lock(f::Function, pool::PormGPostgres, key::AbstractString; wait::Bool=false, timeout_ms::Int=5_000, interval_ms::Int=100, strategy::Symbol=:poll, on_missing_lock::Symbol=:warn)Execute a function f while holding a PostgreSQL session-level advisory lock identified by key.
Advisory locks are an application-level locking mechanism provided by PostgreSQL. They are useful for ensuring exclusivity for tasks that don't map directly to a database row, such as synchronizing external API calls or preventing concurrent expensive calculations.
Arguments
f::Function: The function to execute while holding the lock.pool::PormGPostgres: The PostgreSQL connection pool.key::AbstractString: A unique string identifying the lock. It will be hashed to a 64-bit integer.
Keywords
wait::Bool=false: Iftrue, the function will wait until the lock becomes available or the timeout is reached. Iffalse, it throws an error immediately if the lock is already held.timeout_ms::Int=5_000: Maximum time to wait for the lock (in milliseconds).strategy::Symbol=:poll: The waiting strategy::poll: (Default) Periodically retries lock acquisition from the Julia client. Safe and recommended for most cases.:block: Uses PostgreSQL's server-side blocking mechanism. Efficient but holds a connection and usesstatement_timeout.
interval_ms::Int=100: Retry interval for the:pollstrategy.on_missing_lock::Symbol=:warn: What to do on a backend that cannot lock. Ignored on PostgreSQL, which always takes a real lock — it exists for the SQLite path (see below). An unrecognised value raisesInvalidValueErroron both backends, so a typo cannot lie in wait until you run on SQLite.
SQLite
SQLite has no advisory locks, so with_advisory_lock runs the body with no mutual exclusion. That is deliberate: it lets the same source run against PostgreSQL in production and SQLite in tests, exactly as select_for_update does. Unlike select_for_update, though, what degrades here is a guarantee rather than a query that still returns correct rows — so the SQLite path is not silent. on_missing_lock selects what happens (#277):
on_missing_lock | On SQLite |
|---|---|
:warn (default) | Body runs; warns once per key |
:ignore | Body runs silently — you have accepted the no-op |
:error | Throws BackendCapabilityError; the body does not run |
The other keywords (wait, timeout_ms, strategy, interval_ms) are accepted and ignored on SQLite, and the contention path cannot fire there, so OperationalError is never raised.
The warning is emitted once per distinct key, tracked in-process, for up to 64 keys — the message says so when that cap is reached, rather than going quiet without saying.
Cancelling with Ctrl+C
Interrupting a lock or unlock query does not leak the lock. The connection it ran on is renewed rather than returned to the pool, and a PostgreSQL advisory lock is bound to the session — so reconnecting releases it. That happens on a background task, so the interrupt reaches you immediately; the pool slot stays checked out until the connection is safe to replace. Interrupting the body f is unaffected: the connection is clean there, so the lock is released normally.
Examples
# Lock around a critical update for a specific constructor
PormG.with_advisory_lock(M.Constructor.objects.object.model.connect_key, "update_constructor_1") do
# This block is protected by the lock "update_constructor_1"
# Perform complex logic here...
@info "Exclusive access granted"
end