A simple, ETS-based locking mechanism for local synchronization.
This module provides a lightweight locking system suitable for single-node use cases, such as implementing transactions in local cache adapters. It uses ETS for lock storage with atomic operations for acquiring and releasing locks.
Features
- Atomic lock acquisition - Uses
insert_newfor atomic operations. - Deadlock prevention - Locks are acquired in sorted key order.
- Dual stale lock cleanup - Automatic cleanup via two mechanisms:
- On-demand: During lock acquisition attempts
- Periodic: Background cleanup at configurable intervals
- Fine-grained locking - Lock specific keys for better concurrency.
- Retry with jitter - Configurable retries with jitter to prevent thundering herd.
- Scalable cleanup - Batch-based cleanup using ETS continuation to handle large numbers of locks efficiently.
Usage
The module supports two modes of operation:
Named mode (recommended)
When you provide a :name option, both the GenServer and the ETS table
are registered with that name, allowing direct access without calling
get_table/1:
# Start with a name
children = [
{Nebulex.Locks, name: MyApp.Locks}
]
Supervisor.start_link(children, strategy: :one_for_one)
# Use the name directly for acquire/release
case Nebulex.Locks.acquire(MyApp.Locks, [:key1, :key2], retries: 5) do
:ok ->
try do
# Critical section
after
Nebulex.Locks.release(MyApp.Locks, [:key1, :key2])
end
{:error, :timeout} ->
# Failed to acquire locks
endAnonymous mode
When :name is not provided or is nil, the GenServer starts unnamed
and you must use get_table/1 to obtain the table reference:
# Start without a name
{:ok, pid} = Nebulex.Locks.start_link([])
# Get the table reference
table = Nebulex.Locks.get_table(pid)
# Use the table reference
Nebulex.Locks.acquire(table, [:key1], retries: 3)Start Options
:name(atom/0) - Optional name to register both the GenServer and ETS table. When provided, allows direct access to the locks table using the name. Whennilor not provided, the GenServer starts unnamed.:cleanup_interval(timeout/0) - The interval in milliseconds for the periodic cleanup of stale locks. Stale locks are detected by checking if the owner process is alive and if the lock has exceeded the:lock_timeout. The default value is300000.:cleanup_batch_size(pos_integer/0) - The number of locks to process per batch during periodic cleanup. Larger values reduce iteration overhead but increase memory usage during cleanup. Smaller values reduce memory spikes but increase the number of ETS select operations. The default value is100.:init_callback- An optional callback MFA (module, function, arguments) to be invoked after the locks table is created during GenServer initialization.The callback is invoked as
apply(module, function, [table | args]), wheretableis the newly created ETS locks table prepended to the provided argument list. The return value is ignored.This is useful for integration scenarios where the locks table reference needs to be stored in external state, such as an adapter's metadata table.
Example
# Store the locks table in the adapter's metadata {Nebulex.Locks, [ name: MyApp.Locks, init_callback: {MyApp, :init, [:some_arg]} ]} # Will call: MyApp.init(table, :some_arg)
Lock Entry Structure
Locks are stored in ETS as records:
{:lock, lock_key, owner_pid, timestamp, timeout}Where:
lock_key- The key being locked (sorted to prevent deadlocks).owner_pid- The PID of the process holding the lock.timestamp- When the lock was acquired (for stale detection).timeout- Maximum time the lock can be held before being stale.
Stale Lock Detection and Cleanup
This module implements a defense-in-depth approach to stale lock cleanup, combining two complementary mechanisms:
On-Demand Cleanup
When a process attempts to acquire a lock and finds an existing lock, it checks if the lock is stale before failing the acquisition:
- Dead process detection: Uses
Process.alive?/1to detect if the lock owner has crashed. - Timeout detection: Compares the lock's timestamp with its configured
lock_timeoutto detect locks held longer than allowed.
If a stale lock is detected, it is immediately removed and the acquiring process can obtain the lock. This ensures low latency for lock acquisition on frequently accessed keys.
Periodic Cleanup
A background cleanup process runs at the configured :cleanup_interval
(default: 5 minutes) to proactively remove stale locks. Locks are processed
in batches (default: 100 locks per batch) using ETS continuation to avoid
loading the entire table into memory at once.
This periodic cleanup ensures that stale locks from infrequently accessed keys don't accumulate in memory, preventing memory leaks.
Why Two Mechanisms?
- On-demand cleanup: Provides immediate cleanup for actively used keys with minimal latency.
- Periodic cleanup: Prevents memory leaks by cleaning up locks for keys that are no longer accessed.
- Together: Ensures robust cleanup under all usage patterns.
Performance Characteristics
Lock Acquisition and Release
- No GenServer bottleneck: Lock operations hit ETS directly via atomic
insert_newoperations. Onlyget_table/1requires a GenServer call. - High concurrency: Public ETS table with
:write_concurrencyand:read_concurrencyenabled.
Lock Fairness
- No FIFO guarantee: When multiple processes compete for the same lock, the winner is determined by ETS race conditions and scheduler timing.
- Practical fairness: With retry intervals and jitter, starvation is virtually impossible in practice.
- Trade-off: Prioritizes performance over strict ordering. For use cases
requiring FIFO fairness, consider
sleeplocksor a queue-based approach.
Summary
Functions
Attempts to acquire locks for the given keys.
Returns a specification to start this module under a supervisor.
Returns the locks table from a running GenServer.
Releases locks for the given keys.
Starts the locks GenServer.
Functions
@spec acquire(:ets.table(), [any()], keyword()) :: :ok | {:error, :timeout}
Attempts to acquire locks for the given keys.
Locks are acquired atomically in a sorted order to prevent deadlocks. If any lock cannot be acquired, all partially acquired locks are released and the operation is retried according to the options.
The first parameter can be either a named table (atom) or a table reference.
Options
:keys(list ofterm/0) - The list of keys to lock. If empty or not provided, a global lock is used. For better concurrency, always specify the keys involved in the transaction to enable fine-grained locking. The default value is[].:retries(:infinity|non_neg_integer/0) - The number of times to retry acquiring locks before giving up. If set to:infinity, the process will retry indefinitely until locks are acquired. The default value is3.:retry_interval(non_neg_integer/0|(attempt :: non_neg_integer() -> non_neg_integer())) - The time in milliseconds to wait between lock acquisition retries.Can be either:
- A timeout value (
non_neg_integer()) - A fixed interval. When using a non-negative integer, a small random jitter is added to prevent thundering herd issues. - An anonymous function - Receives the current attempt number
(0-indexed) and must return a non-negative integer representing the
interval in milliseconds. No jitter is added, giving you full control
over the retry strategy. Returning
0results in immediate retry with no delay.
Examples:
# Fixed interval with automatic jitter retry_interval: 10 # Exponential backoff retry_interval: fn attempt -> round(min(100 * :math.pow(2, attempt), 5000)) end # Linear backoff retry_interval: fn attempt -> 10 + (attempt * 50) end # Immediate retry on first attempt, then backoff retry_interval: fn 0 -> 0 attempt -> attempt * 100 end # Custom strategy with manual jitter retry_interval: fn attempt -> base = 100 * attempt jitter = :rand.uniform(max(base, 1)) base + jitter endThe default value is
10.- A timeout value (
:lock_timeout(timeout/0) - The maximum time in milliseconds a lock can be held before being considered stale. Stale locks (from crashed processes or timeouts) are automatically cleaned up during acquisition attempts. The default value is30000.
Examples
# Using a named table
iex> {:ok, _pid} = Nebulex.Locks.start_link(name: MyLocks)
iex> Nebulex.Locks.acquire(MyLocks, [:key1, :key2])
:ok
# Using a table reference
iex> {:ok, pid} = Nebulex.Locks.start_link([])
iex> table = Nebulex.Locks.get_table(pid)
iex> Nebulex.Locks.acquire(table, [:key1])
:ok
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec get_table(GenServer.server()) :: :ets.table()
Returns the locks table from a running GenServer.
This function is needed when starting the GenServer without a name.
For named GenServers, you can use the name directly with acquire/3
and release/2.
Examples
# Named mode - table name is the same as GenServer name
iex> {:ok, _pid} = Nebulex.Locks.start_link(name: MyLocks)
iex> table = Nebulex.Locks.get_table(MyLocks)
iex> table == MyLocks
true
# Anonymous mode - returns a table reference
iex> {:ok, pid} = Nebulex.Locks.start_link([])
iex> table = Nebulex.Locks.get_table(pid)
iex> is_reference(table)
true
@spec release(:ets.table(), [any()]) :: :ok
Releases locks for the given keys.
This function is typically called in an after block to ensure
locks are released even if an exception occurs.
This API assumes trusted callers: it does not verify lock ownership when deleting lock entries. Releasing keys not owned by the current process is considered undefined behavior.
The first parameter can be either a named table (atom) or a table reference.
Examples
# Using a named table
iex> {:ok, _pid} = Nebulex.Locks.start_link(name: MyLocks)
iex> Nebulex.Locks.acquire(MyLocks, [:key1, :key2])
:ok
iex> Nebulex.Locks.release(MyLocks, [:key1, :key2])
:ok
# Using a table reference
iex> {:ok, pid} = Nebulex.Locks.start_link([])
iex> table = Nebulex.Locks.get_table(pid)
iex> Nebulex.Locks.acquire(table, [:key1])
:ok
iex> Nebulex.Locks.release(table, [:key1])
:ok
@spec start_link(keyword()) :: GenServer.on_start()
Starts the locks GenServer.
Options
:name(atom/0) - Optional name to register both the GenServer and ETS table. When provided, allows direct access to the locks table using the name. Whennilor not provided, the GenServer starts unnamed.:cleanup_interval(timeout/0) - The interval in milliseconds for the periodic cleanup of stale locks. Stale locks are detected by checking if the owner process is alive and if the lock has exceeded the:lock_timeout. The default value is300000.:cleanup_batch_size(pos_integer/0) - The number of locks to process per batch during periodic cleanup. Larger values reduce iteration overhead but increase memory usage during cleanup. Smaller values reduce memory spikes but increase the number of ETS select operations. The default value is100.:init_callback- An optional callback MFA (module, function, arguments) to be invoked after the locks table is created during GenServer initialization.The callback is invoked as
apply(module, function, [table | args]), wheretableis the newly created ETS locks table prepended to the provided argument list. The return value is ignored.This is useful for integration scenarios where the locks table reference needs to be stored in external state, such as an adapter's metadata table.
Example
# Store the locks table in the adapter's metadata {Nebulex.Locks, [ name: MyApp.Locks, init_callback: {MyApp, :init, [:some_arg]} ]} # Will call: MyApp.init(table, :some_arg)