# `Nebulex.Locks`
[🔗](https://github.com/elixir-nebulex/nebulex_local/blob/v3.0.0/lib/nebulex/locks.ex#L1)

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:

### 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
    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` (`t: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` (`t: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` (`t: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.

# `acquire`

```elixir
@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 `t: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` | `t: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` (`t: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` (`t: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`

Returns a specification to start this module under a supervisor.

See `Supervisor`.

# `get_table`

```elixir
@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`
*macro* 

# `lock`
*macro* 

# `release`

```elixir
@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`

```elixir
@spec start_link(keyword()) :: GenServer.on_start()
```

Starts the locks GenServer.

## Options

* `:name` (`t: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` (`t: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` (`t: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)

---

*Consult [api-reference.md](api-reference.md) for complete listing*
