defmodule Services.Once do @moduledoc """ This module provides a mechanism to perform actions only once, using a unique key provided by the caller to determine whether the action has already been performed this session. The agent is tree-scoped rather than VM-global: `start_link/0` starts an unnamed agent and registers it for the current process tree via `Services.Instance`. Each instance root (a test, a `Fnord.Instance` checkout, or the escript's main process in production) gets its own notion of "once", so seen-keys cannot leak between trees sharing a BEAM. """ use Agent @doc """ Starts the agent that keeps track of seen keys and registers it as the current process tree's instance. """ def start_link() do with {:ok, pid} <- Agent.start_link(fn -> Map.new() end) do Services.Instance.register(__MODULE__, pid) {:ok, pid} end end @doc """ True when a Services.Once instance is running in the current process tree. Callers that may be exercised outside a live session (without the service roster running) use this to no-op instead of crashing. """ @spec running?() :: boolean() def running?() do Services.Instance.whereis(__MODULE__) != nil end @doc """ Checks if a key has been seen before. If the key has not been seen, it returns `{:error, :not_seen}`. If the key has been seen, it returns `{:ok, value}` where `value` is the value associated with the key, or `true` if no value was specified. """ def get(key) do Agent.get(instance(), fn seen -> with {:ok, value} <- Map.fetch(seen, key) do {:ok, value} else _ -> {:error, :not_seen} end end) end @doc """ Marks a key as seen. If the key has not been seen before, it returns `true` and updates the internal state. If the key has already been seen, it returns `false` without updating the state. """ def set(key, value \\ true) do Agent.get_and_update(instance(), fn seen -> if Map.has_key?(seen, key) do {false, seen} else {true, Map.put(seen, key, value)} end end) end @doc """ Emits a warning (using `UI.warn/1`) if the message has not yet been emitted during this session. """ def warn(msg) do if set(msg) do UI.warn(msg) end :ok end @doc """ Run the given zero-arity function only once per unique key. Subsequent calls with the same key will be ignored. """ @spec run(term(), (-> any())) :: any() | :ignore def run(key, fun) when is_function(fun, 0) do case set(key) do true -> fun.() false -> :ignore end end defp instance() do Services.Instance.fetch!(__MODULE__) end end