Revenant
Status: experiment. This library is an exploration and is not yet production ready. APIs, guarantees, and the storage format may change without notice.
Durable GenServers backed by Postgres. A reply is a commit receipt.
Revenant gives you process-per-entity GenServers whose state survives crashes, restarts, and deploys, using only the Postgres you already run. No object storage, no new infrastructure. Single node - or you route each entity's messages to one node yourself; see Topology.
defmodule Account do
use Revenant, repo: MyApp.Repo
def initial_state(_id), do: %{balance: 0}
def handle_call({:deposit, amount}, _from, state) do
{:reply, :ok, %{state | balance: state.balance + amount}}
end
def handle_call(:balance, _from, state) do
{:reply, state.balance, state}
end
end
Revenant.call({Account, "acct_42"}, {:deposit, 100})
#=> :ok - the new state is committed to Postgres before you see this
Processes are addressed by {module, entity_id}, not by pid. They start
lazily on the first message and revive from committed state after any kind
of death - a crash, a deploy, a scale-down. Callers never notice.
Idle processes passivate: after :idle_timeout milliseconds without a
message (default 5 minutes) a process flushes pending state and stops, and
the next message revives it. Memory tracks your working set, not every
entity ever touched. Set idle_timeout: :infinity to keep processes
resident; note that :timeout is a reserved info message on any server
with a finite idle timeout.
Topology
The registry is node-local. Revenant is built for a single node - the deployment most apps actually run - or for clusters where you already route each entity's messages to one node (consistent hashing, a fronting queue, sticky sessions).
If two nodes do load the same entity, your data stays safe: every write is
version-fenced, so the stale process exits with {:revenant_conflict, key}
instead of overwriting. But under relaxed durability the losing node has
already acked writes it can no longer commit. If you cannot guarantee
routing, use :strict, where every ack is already durable.
The guarantee
With the default :strict durability, the state change is committed to
Postgres before the caller receives its reply. There is no window
between "acknowledged" and "durable":
- Anything a caller ever observed survives a crash.
- A handler crash before commit rolls back to the last committed state - a poison message cannot persist a state nobody was acked on.
- Every write is guarded by a version column: a stale process exits with
{:revenant_conflict, key}rather than overwriting a newer commit.
Durability levels
Strictness has a price: one Postgres commit per mutation, so per-entity write throughput is capped by commit latency (roughly 1ms locally). When an entity takes many low-value writes, relax it - and escalate the calls that matter:
use Revenant, repo: MyApp.Repo, durability: {:interval, 5_000}
Revenant.call({Session, id}, {:track, event}) # coalesced, flushed within 5s
Revenant.call({Session, id}, {:checkout, cart}, durability: :strict) # committed before this reply
| Level | Flushes | Loss window on hard crash |
|---|---|---|
:strict (default) | before every reply | none |
{:interval, ms} | at most once per interval + on shutdown | up to one interval |
:on_stop | only on graceful shutdown | the whole session |
All levels flush on graceful shutdown - and passivation is a graceful
shutdown - so deploys, scale-downs, and idle stops lose nothing in any
mode. The loss window exists only for kill -9 and power loss.
Setup
# mix.exs
{:revenant, "~> 0.1"}
# a migration
defmodule MyApp.Repo.Migrations.AddRevenant do
use Revenant.Migration
end
# application.ex, after your repo
children = [MyApp.Repo, Revenant.Supervisor]
State rules
State is stored as an External Term Format blob, so any Elixir term round-trips exactly - except runtime handles. No pids, references, ports, or anonymous functions (captures of named functions are fine). Enable the deep check in dev and test:
config :revenant, validate_state: true
When the shape of your state changes between releases, bump :vsn and
migrate old snapshots as they load:
use Revenant, repo: MyApp.Repo, vsn: 2
def upgrade(1, state), do: Map.put(state, :currency, :usd)
Snapshots are decoded without :safe, so state referencing modules or
atoms deleted since the snapshot was written still loads and reaches your
upgrade/2 - a renamed struct arrives as a plain map with its old
__struct__ for you to migrate.
To remove an entity entirely - stop its process and drop its row - use
Revenant.delete({Account, id}). The next call starts it fresh from
initial_state/1.
Telemetry
[:revenant, :flush]- a snapshot was committed; measurements%{bytes: n}, metadata%{module, id, version}[:revenant, :conflict]- a stale process lost a version race and is exiting; metadata%{module, id, version}[:revenant, :load]- a process revived from a committed snapshot (not first-ever starts); metadata%{module, id, version}
When not to use this
If init/1 could rebuild your state from tables you already have, you do
not need Revenant - you need a plain GenServer with a good init.
Revenant is for state whose only source of truth is the process itself:
live sessions, in-progress matches, actor-as-aggregate write models.
Side effects are not journaled. A handler that sends an email and then crashes before commit will send the email again on the retry. For durable effects, enqueue an Oban job - it is also just Postgres.