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
end

Configuration and Strategies

Waiting Strategies

When a lock is already held by another session, you can choose how PormG should behave:

  1. Non-blocking (wait=false): Immediately raises OperationalError if the lock cannot be acquired. The same type is raised when a :poll or :block acquisition exceeds timeout_ms.
  2. Client Polling (strategy=:poll): (Default) PormG will try to acquire the lock, wait for a few milliseconds, and try again until the timeout_ms is reached.
  3. 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 :poll strategy, the timeout is managed by Julia.
  • In :block strategy, PormG temporarily sets the PostgreSQL statement_timeout for that specific acquisition.

Implementation Details

  • PostgreSQL: Implementation uses pg_try_advisory_lock (non-blocking) or pg_advisory_lock (blocking).
  • SQLite: with_advisory_lock is a no-op — see the warning below.
  • Async Safety: with_advisory_lock uses LibPQ.async_execute and fetch() to ensure that the Julia task yields while waiting for the database, keeping the event loop unblocked.
Advisory locks are a no-op on SQLite

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_lockOn SQLiteUse it when
:warn (default)Body runs; warns once per keyYou want to be told, but not stopped
:ignoreBody runs, silentlyYou have read this page and accepted the no-op
:errorRaises BackendCapabilityError; the body does not runThe 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
end

On 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_lockFunction
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: If true, the function will wait until the lock becomes available or the timeout is reached. If false, 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 uses statement_timeout.
  • interval_ms::Int=100: Retry interval for the :poll strategy.
  • 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 raises InvalidValueError on 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_lockOn SQLite
:warn (default)Body runs; warns once per key
:ignoreBody runs silently — you have accepted the no-op
:errorThrows 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
source