The arbiter contract (design §5.1, implementation.md §3): leases, membership, the epoch-fenced vnode table, and fencing-token validation.
Every operation must be implementable as one statement / one transaction
(PgBouncer transaction mode is the native grain). {:error, :unreachable}
is distinct from {:error, :expired} because Fief.Node treats them
differently: unreachable starts the fence clock; expired fences now.
All callbacks take the adapter handle first (a pid or registered name);
adapters are started per instance by Fief.Supervisor. Lease expiry is
judged on the arbiter's clock, never the caller's — the returned lease
carries the granted TTL, and the caller derives its own deadline from its
send-time basis (design §6.4).
Epoch semantics: one per-namespace epoch, bumped by every successful membership or table write — no non-bumping writes exist. Reads return the epoch they observed. Leader terms are validated on every CAS: lower than the highest term ever seen is rejected, and the max ratchets forward.
Summary
Callbacks
Grant a fresh lease. :held if an unexpired lease for node_id exists.
Assign vnode to new_owner, guarded by epoch equality and term ratchet.
Assigning over a live owner opens a transfer (prev_owner = old owner);
if a transfer is already open, prev_owner is left pointing at the original
donor — the planner settles before chaining further.
Clear prev_owner (transfer complete), same guards as cas_assign/5.
First-writer-wins immutable namespace config (partitions, protocol version,
impl fingerprint — design §6.1). Writes config if the namespace has none
and returns the authoritative config either way; the caller compares and
refuses to join on mismatch.
The node ids holding a live lease, judged on the arbiter's clock — the
planner's authoritative liveness source (M4 phase B, forced addition):
reassignment after lease death (design §6.4) must be gated on the arbiter's
own judgement of expiry, and Presence is hint-grade by construction, so the
planner needs a lease read in the safety contract. Single-statement
implementable (SELECT node FROM fief_leases WHERE ns = $1 AND expires_at > now()).
Full table read: vnode => {owner, prev_owner, row_epoch}. prev_owner nil
= settled. row_epoch is the epoch produced by the last successful CAS on
this row (M4 decision): the namespace epoch bumps on every write anywhere,
so the transfer-session identity of design §6.3 — (vnode, donor, recipient, epoch-at-assign) — must be derivable by both endpoints from the row itself.
Drop the lease. Idempotent.
Extend a live lease from the arbiter's now. :expired if dead or absent.
Types
@type config() :: map()
@type epoch() :: non_neg_integer()
@type leader_term() :: pos_integer()
@type lease() :: %{node: node_id(), ttl_ms: pos_integer()}
@type node_id() :: term()
@type status() :: :joining | :active | :leaving
@type store() :: GenServer.server()
@type vnode_id() :: non_neg_integer()
Callbacks
@callback acquire(store(), node_id(), ttl_ms :: pos_integer()) :: {:ok, lease()} | {:error, :held | :unreachable}
Grant a fresh lease. :held if an unexpired lease for node_id exists.
@callback cas_assign(store(), vnode_id(), node_id(), epoch(), leader_term()) :: {:ok, epoch()} | {:error, :stale | :unreachable}
Assign vnode to new_owner, guarded by epoch equality and term ratchet.
Assigning over a live owner opens a transfer (prev_owner = old owner);
if a transfer is already open, prev_owner is left pointing at the original
donor — the planner settles before chaining further.
@callback cas_settle(store(), vnode_id(), epoch(), leader_term()) :: {:ok, epoch()} | {:error, :stale | :unreachable}
Clear prev_owner (transfer complete), same guards as cas_assign/5.
First-writer-wins immutable namespace config (partitions, protocol version,
impl fingerprint — design §6.1). Writes config if the namespace has none
and returns the authoritative config either way; the caller compares and
refuses to join on mismatch.
The node ids holding a live lease, judged on the arbiter's clock — the
planner's authoritative liveness source (M4 phase B, forced addition):
reassignment after lease death (design §6.4) must be gated on the arbiter's
own judgement of expiry, and Presence is hint-grade by construction, so the
planner needs a lease read in the safety contract. Single-statement
implementable (SELECT node FROM fief_leases WHERE ns = $1 AND expires_at > now()).
@callback read_table(store()) :: {:ok, %{required(vnode_id()) => assignment()}, epoch()}
Full table read: vnode => {owner, prev_owner, row_epoch}. prev_owner nil
= settled. row_epoch is the epoch produced by the last successful CAS on
this row (M4 decision): the namespace epoch bumps on every write anywhere,
so the transfer-session identity of design §6.3 — (vnode, donor, recipient, epoch-at-assign) — must be derivable by both endpoints from the row itself.
Drop the lease. Idempotent.
@callback renew(store(), node_id(), ttl_ms :: pos_integer()) :: {:ok, lease()} | {:error, :expired | :unreachable}
Extend a live lease from the arbiter's now. :expired if dead or absent.