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