Skip to main content

lib/ex_sql/registry.ex

defmodule ExSQL.Registry do
  @moduledoc """
  An in-VM "commit version" per database path.

  File-backed connections share a database through its file: each persists the
  whole file after a committed write, and (historically) re-parsed the whole
  file before each read so it could observe another connection's commits. That
  reparse is pure waste in the common single-writer case, where a connection's
  in-memory `ExSQL.Database` is already authoritative.

  This registry lets a connection tell whether its snapshot is still current
  without touching the file: a monotonically increasing counter, keyed by
  database path, is bumped once per committed file write. A connection records
  the version it last synced to; when the registry version is unchanged, the
  reload can be skipped.

  The counter lives in a `:public` ETS table owned by this process (started
  under the application's supervision tree) so it outlives any individual connection. Reads and
  bumps go straight to ETS — no `GenServer` round-trip on the hot path.

  A file-stat fingerprint (`{mtime, size}`) is deliberately *not* used: a
  whole-file rewrite of a small change can keep the size identical, and POSIX
  `mtime` is second-resolution, so two writes plus a read within one second can
  look unchanged — skipping a reload that was actually required. An in-VM
  counter has neither problem (within one VM, the supported deployment).

  If the table is absent (the `:exsql` application was not started), the
  functions return `:no_registry` and callers fall back to always reloading —
  the original, correct-but-slower behavior.
  """

  use GenServer

  @table __MODULE__

  @doc "Starts the registry, creating its ETS table."
  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, :ok, Keyword.put_new(opts, :name, __MODULE__))
  end

  @doc """
  Returns the current commit version for `path` (0 if never written), or
  `:no_registry` if the registry is not running.
  """
  @spec current_version(term()) :: non_neg_integer() | :no_registry
  def current_version(path) do
    case :ets.whereis(@table) do
      :undefined ->
        :no_registry

      _ref ->
        case :ets.lookup(@table, path) do
          [{^path, version}] -> version
          [] -> 0
        end
    end
  end

  @doc """
  Atomically bumps and returns the commit version for `path`, or `:no_registry`
  if the registry is not running.
  """
  @spec bump(term()) :: non_neg_integer() | :no_registry
  def bump(path) do
    case :ets.whereis(@table) do
      :undefined -> :no_registry
      _ref -> :ets.update_counter(@table, path, {2, 1}, {path, 0})
    end
  end

  @impl true
  def init(:ok) do
    _table =
      :ets.new(@table, [
        :named_table,
        :public,
        :set,
        read_concurrency: true,
        write_concurrency: true
      ])

    {:ok, %{}}
  end
end