Nebulex.Locks (nebulex_local v3.0.0)

Copy Markdown View Source

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_new for 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:

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
end

Anonymous 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. When nil or 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 is 300000.

  • :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 is 100.

  • :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]), where table is 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?/1 to detect if the lock owner has crashed.
  • Timeout detection: Compares the lock's timestamp with its configured lock_timeout to 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_new operations. Only get_table/1 requires a GenServer call.
  • High concurrency: Public ETS table with :write_concurrency and :read_concurrency enabled.

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 sleeplocks or 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

acquire(table, keys, opts \\ [])

@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 of term/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 is 3.

  • :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 0 results 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
    end

    The default value is 10.

  • :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 is 30000.

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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

get_table(server)

@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

lock(args \\ [])

(macro)

lock(record, args)

(macro)

release(table, keys)

@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

start_link(opts)

@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. When nil or 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 is 300000.

  • :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 is 100.

  • :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]), where table is 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)