GgenIgniter.Lock (ggen_igniter v26.9.8)

Copy Markdown View Source

A real, file-based cross-process lock used by mix ggen_igniter.sync (see Mix.Tasks.GgenIgniter.Sync's AR-9 correction, 2026-08-27) to serialize concurrent mutating invocations against the same target project. Per the PRD's FR-5 (see Mix.Tasks.GgenIgniter.Plan's moduledoc, "Read-only, no lock"), only verbs that mutate a target project's filesystem (mix ggen_igniter.sync, mix ggen_igniter.replay) acquire this lock -- mix ggen_igniter.doctor and mix ggen_igniter.plan are both read-only and must never call acquire/2.

Mechanism

A real lock FILE (.ggen_igniter/.sync.lock, under the caller's lock_key directory -- typically --manifest-dir or File.cwd!/0), created with File.open/2's :exclusive mode -- the OS itself refuses a second concurrent :exclusive open against the same path, so two BEAM processes (or two separate mix invocations, which is the real scenario this exists for -- not two processes in the same VM) genuinely cannot both win the race. This is not an in-memory/:global/GenServer-registered lock: those only serialize callers inside the SAME BEAM node, which is not what "two concurrent mix ggen_igniter.sync invocations" actually are.

Stale-lock recovery

Staleness is decided by TWO real signals, PID-liveness first and mtime-age as the fallback -- this fixes a real gap where a legitimately slow same-VM run past @stale_after_ms (5 minutes) used to get its lock stolen by a second concurrent invocation purely because the file was "old", even though the original holder process was still alive and still working:

  • PID-liveness (primary): holder_marker/0 writes the acquiring process's real erlang_pid= (its own self(), inspect/1-formatted) alongside node= into the lock file's content. When a later acquire/2 call hits :eexist on that same node, holder_pid_status/1 parses that Erlang pid back out via :erlang.list_to_pid/1 and checks Process.alive?/1 for real -- no periodic background heartbeat/refresher process is needed, since liveness is checked fresh, on demand, at contention time. A confirmed live holder is never preempted, however old its file's mtime is; a confirmed dead holder (the process genuinely exited) is immediately reclaimable, however fresh its file's mtime is.
  • mtime-age (fallback): used only when PID-liveness is :unknown -- the recorded node= differs from Node.self() (the real, disclosed cross-node limitation: an Erlang pid from another node's local process table cannot be resolved locally), the marker line is missing/ unparseable, or the OS pid was reused by an unrelated process after a hard crash. In that fallback case only, a lock file older than 5 minutes is treated as abandoned (its holder crashed, was killed, or the machine restarted, without ever reaching release/1) and is removed automatically by the next acquire/2 caller before retrying. A live holder well inside that window is never preempted.

Real functions, no mock anywhere in this chain

  • acquire/2 -- blocks (retrying on a real interval) until the real lock file is created or opts[:timeout_ms] elapses, then raises a RuntimeError naming the still-held lock path. Returns {:ok, %__MODULE__{}} on success -- the ref release/1 requires.
  • release/1 -- deletes the real lock file. Idempotent: releasing a lock whose file was already removed (e.g. cleaned up out-of-band) is a no-op, never a raise, since every real call site (Mix.Tasks.GgenIgniter.Sync's try/after) needs "leave nothing held" to succeed unconditionally.

Summary

Functions

Acquires the real cross-process lock for lock_key (a directory path -- typically --manifest-dir or File.cwd!/0). Blocks, retrying every opts[:retry_interval_ms] (default 50ms), until either the lock file is genuinely created by THIS process or opts[:timeout_ms] (default 30000ms) elapses -- at which point it raises a RuntimeError naming the still-held lock path, rather than returning a value the existing {:ok, lock_ref} = acquire(...) call sites do not pattern-match against.

Releases a previously-acquired lock by deleting its real lock file. Idempotent -- a lock file already missing (removed out-of-band, or by a stale-lock recovery elsewhere) is treated as already-released, never a raise.

Types

t()

@type t() :: %GgenIgniter.Lock{key: String.t(), path: String.t()}

Functions

acquire(lock_key, opts \\ [])

@spec acquire(
  String.t(),
  keyword()
) :: {:ok, t()}

Acquires the real cross-process lock for lock_key (a directory path -- typically --manifest-dir or File.cwd!/0). Blocks, retrying every opts[:retry_interval_ms] (default 50ms), until either the lock file is genuinely created by THIS process or opts[:timeout_ms] (default 30000ms) elapses -- at which point it raises a RuntimeError naming the still-held lock path, rather than returning a value the existing {:ok, lock_ref} = acquire(...) call sites do not pattern-match against.

Returns {:ok, t()} on success.

release(lock)

@spec release(t()) :: :ok

Releases a previously-acquired lock by deleting its real lock file. Idempotent -- a lock file already missing (removed out-of-band, or by a stale-lock recovery elsewhere) is treated as already-released, never a raise.