cover/Elixir.WalletTransfers.LockStore.html

1 defmodule WalletTransfers.LockStore do
2 @moduledoc """
3 ETS-backed GenServer for transfer-level optimistic locking per ADR 0004.
4
5 Provides per-transfer locks to prevent concurrent state transitions on the
6 same transfer. A lock is tied to the acquiring process's PID; if that process
7 is no longer alive, the lock is considered stale and can be re-acquired.
8
9 Table: `:wallet_transfers_locks` keyed by transfer_id with
10 `{transfer_id, owner_pid, acquired_at}`.
11
12 ## Lock semantics
13 - If no entry exists, the lock is granted.
14 - If an entry exists and the owner PID is still alive, return `{:error, :locked}`.
15 - If an entry exists but the owner PID is dead (stale lock), allow re-acquisition.
16 """
17
18 use GenServer
19
20 @locks_table :wallet_transfers_locks
21
22 # --- Client API ---
23
24 def start_link(opts) do
25
:-(
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
26 end
27
28 @doc """
29 Acquires a lock on a transfer.
30
31 Returns `:ok` if the lock was acquired, or `{:error, :locked}` if another
32 live process already holds the lock.
33 """
34 @spec acquire(transfer_id :: String.t()) :: :ok | {:error, :locked}
35 def acquire(transfer_id) do
36 84 GenServer.call(__MODULE__, {:acquire, transfer_id, self()})
37 end
38
39 @doc """
40 Releases a lock on a transfer.
41
42 Returns `:ok` regardless of whether this process held the lock.
43 """
44 @spec release(transfer_id :: String.t()) :: :ok
45 def release(transfer_id) do
46 59 GenServer.call(__MODULE__, {:release, transfer_id})
47 end
48
49 @doc "Resets all locks. For test use only."
50 44 def reset, do: GenServer.call(__MODULE__, :reset)
51
52 # --- Server Callbacks ---
53
54 @impl true
55 def init(_opts) do
56
:-(
:ets.new(@locks_table, [:set, :protected, :named_table])
57 {:ok, %{}}
58 end
59
60 @impl true
61 def handle_call({:acquire, transfer_id, caller_pid}, _from, state) do
62 84 result =
63 case :ets.lookup(@locks_table, transfer_id) do
64 [] ->
65 58 :ets.insert(@locks_table, {transfer_id, caller_pid, DateTime.utc_now()})
66 :ok
67
68 [{^transfer_id, owner_pid, _acquired_at}] ->
69 26 if Process.alive?(owner_pid) do
70 {:error, :locked}
71 else
72 # Stale lock — owner process is dead; allow re-acquisition
73 1 :ets.insert(@locks_table, {transfer_id, caller_pid, DateTime.utc_now()})
74 :ok
75 end
76 end
77
78 84 {:reply, result, state}
79 end
80
81 @impl true
82 def handle_call({:release, transfer_id}, _from, state) do
83 59 :ets.delete(@locks_table, transfer_id)
84 59 {:reply, :ok, state}
85 end
86
87 @impl true
88 def handle_call(:reset, _from, state) do
89 44 :ets.delete_all_objects(@locks_table)
90 44 {:reply, :ok, state}
91 end
92 end
Line Hits Source