Gamend.Repo.AdvisoryLock (gamend_core v1.0.1296)

Copy Markdown View Source

Advisory locking for protecting TOCTOU (Time-of-Check-Time-of-Use) patterns.

On PostgreSQL, acquires a transaction-scoped advisory lock via pg_advisory_xact_lock(namespace, resource_id). The lock is automatically released when the enclosing Repo.transaction commits or rolls back.

On SQLite, this function is a no-op, because SQLite has no advisory locks. That is a fact about this function, not a claim that locking is unnecessary there: SQLite serializes each write, which is not the same as serializing a read-modify-write spanning statements, and does nothing at all for a critical section held over ETS or process state.

Callers should not use this module directly for that reason. Go through Gamend.Lock.serialize/3, which picks this on Postgres and a keyed :global mutex (Gamend.Lock.Local) everywhere else, so the guarantee holds on both adapters.

Usage

Always call within a transaction, opened through Gamend.AfterCommit so broadcasts inside wait for the commit (or use Gamend.Lock.serialize/3):

Gamend.AfterCommit.transaction(fn ->
  AdvisoryLock.lock(:lobby, lobby.id)
  count = count_members(lobby.id)
  if count >= lobby.max_users, do: Repo.rollback(:full)
  do_join(...)
end)

Namespaces

Each resource type uses a distinct integer namespace to avoid collisions. The registered atoms (namespaces/0 returns the same map):

  • :lobby → 1
  • :group → 2
  • :party → 3
  • :friendship → 4
  • :tournament_draw → 5
  • :tournament_match → 6
  • :tournaments_tick → 7
  • :matchmaking_sweep → 8
  • :quest → 9
  • :push_tokens → 10
  • :ready_check → 11
  • :tournament_join → 12

A new atom namespace is added to @namespaces (ids 0..99 are reserved for atoms).

You can also pass an arbitrary string as the namespace. The string is hashed to a stable 32-bit integer via :erlang.phash2/2, so any string (e.g. "word_guessed", "my_rpc") works without pre-registration.

Examples

# Atom namespace (predefined):
AdvisoryLock.lock(:lobby, lobby_id)

# String namespace (ad-hoc):
AdvisoryLock.lock("word_guessed", lobby_id)

Summary

Functions

Acquire a transaction-scoped advisory lock for the given resource.

Takes the session-level lock for (namespace, resource_id) on the current connection, waiting as long as it takes. It is held until unlock_session/2 or until the connection closes, not until a transaction ends, for a job that commits many transactions of its own under one lock (Gamend.Lock.exclusive/3).

The integer namespace pg_advisory_xact_lock is called with.

The registered lock namespaces and their ids (for introspection).

Returns true if the Repo was compiled with the PostgreSQL adapter.

Releases a lock lock_session/2 took on this connection.

Functions

lock(namespace, resource_id)

@spec lock(atom() | String.t(), String.t()) :: :ok

Acquire a transaction-scoped advisory lock for the given resource.

namespace can be a registered atom (see namespaces/0) or any arbitrary string. resource_id is a UUID string; it is hashed to a stable 32-bit integer for pg_advisory_xact_lock (a hash collision only causes extra serialization, never lost mutual exclusion).

Must be called inside a Repo.transaction. On PostgreSQL, blocks until the lock is available. On SQLite, returns immediately — see the moduledoc.

lock_session(namespace, resource_id)

@spec lock_session(atom() | String.t(), String.t()) :: :ok

Takes the session-level lock for (namespace, resource_id) on the current connection, waiting as long as it takes. It is held until unlock_session/2 or until the connection closes, not until a transaction ends, for a job that commits many transactions of its own under one lock (Gamend.Lock.exclusive/3).

Postgres only. Call it inside Repo.checkout/2, so the lock, the work and the unlock share one connection. It shares its key space with lock/2.

namespace_id(namespace)

@spec namespace_id(atom() | String.t()) :: non_neg_integer()

The integer namespace pg_advisory_xact_lock is called with.

Public so Gamend.Lock.serialize/3 can resolve it on every adapter, not only Postgres. An unregistered atom is a programming error, and it used to surface as a KeyError from deep inside the Postgres branch — while the SQLite branch never calls lock/2 at all and so never noticed. Anyone developing on the default SQLite setup could therefore add a lock with an unregistered namespace, watch every local test pass, and only find out on the Postgres CI job.

namespaces()

The registered lock namespaces and their ids (for introspection).

postgres?()

@spec postgres?() :: boolean()

Returns true if the Repo was compiled with the PostgreSQL adapter.

unlock_session(namespace, resource_id)

@spec unlock_session(atom() | String.t(), String.t()) :: :ok

Releases a lock lock_session/2 took on this connection.