YmerNode.Notebook.Backup.Lock (Ymer Node v0.2.1)

Copy Markdown View Source

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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

current()

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.

start_link(opts)

with_lock(op, fun)

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.