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":

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
LevelFlushesLoss window on hard crash
:strict (default)before every replynone
{:interval, ms}at most once per interval + on shutdownup to one interval
:on_stoponly on graceful shutdownthe 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

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.