Singleton mutex serializing YmerNode.Notebook.Backup operations — capture and
restore.
Why a mutex, and why the work runs in the caller
A capture (VACUUM INTO) or a restore (stop repo, swap file, restart repo) is
slow and must never run twice at once against the same files. This GenServer
holds only the lock state; with_lock/2 runs the actual work in the calling
process while it holds the lock. That keeps a possibly multi-second operation
out of this GenServer's mailbox, where it could trip a call timeout, and it
means a second operation is rejected — {:error, :operation_in_progress} —
rather than silently queued behind the first. A caller that asked to back up and
got told "not now" can retry; one whose call sat in a queue for a minute cannot
tell the difference between slow and stuck.
The holder pid is monitored, so a caller that crashes mid-operation frees the
lock on its :DOWN and cannot wedge the singleton.
What a restart of this process costs
Mutual exclusion does not survive a restart of this process under a running
operation. The restarted lock's state is %{holder: nil}, so it answers
:idle while a restore is still swapping files, and a second operation
acquires. That is accepted rather than closed: the crash has no known trigger —
handle_call/3's bodies are constant-time and the process holds one small
map — so machinery to re-register an in-flight holder would guard a path
nothing has ever taken.
What is not accepted is a caller losing a finished operation's answer to the
same window, which is reachable from every restart OTP performs. Both of
with_lock/2's calls therefore contain their own exits, and current/0
answers :unreachable rather than exiting into a caller that cannot catch it.
The application's supervision tree starts this process after the repo, so the lock exists before anything can ask for it.
Summary
Functions
Returns a specification to start this module under a supervisor.
Returns :idle, {:busy, op}, or :unreachable — the current lock state.
Acquires the lock, runs fun in the caller process, and releases — returning
whatever fun returns. If another operation already holds the lock, does not
run fun and returns {:error, :operation_in_progress}. If the acquire does
not come back — this process inside its own restart window, or alive and not
answering within its call timeout — does not run fun either and returns
{:error, :lock_unreachable}: a different fact from a held lock, and one the
caller maps rather than catches. An acquire that timed out is cancelled behind
itself, so a lock that answers late does not stay held by a caller that has
already given up.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
Returns :idle, {:busy, op}, or :unreachable — the current lock state.
:unreachable is this module's own word for "the call did not come back" —
the same two exits with_lock/2 names, contained here, logged, and answered
as a third value, so no caller has to catch an exit to read the lock. The
:noproc of the restart window answers at once; against a lock that is alive
and not answering, this read costs the caller the call timeout before it
answers.
For tests, and for YmerNode.Notebook, which reads it to tell a restore's
window — the one stopped state that closes on its own — from every other one;
see its moduledoc. A caller-facing answer turns on the exact :idle /
{:busy, op} / :unreachable split, and on the held op inside the second, so
none of them is free to drift.
Acquires the lock, runs fun in the caller process, and releases — returning
whatever fun returns. If another operation already holds the lock, does not
run fun and returns {:error, :operation_in_progress}. If the acquire does
not come back — this process inside its own restart window, or alive and not
answering within its call timeout — does not run fun either and returns
{:error, :lock_unreachable}: a different fact from a held lock, and one the
caller maps rather than catches. An acquire that timed out is cancelled behind
itself, so a lock that answers late does not stay held by a caller that has
already given up.
The lock is released even if fun raises, and, as a backstop, if the caller
process dies. A release that lands inside the restart window is logged and
discarded: it never replaces what fun answered.