Fief.StateStore behaviour (Fief v0.1.0)

Copy Markdown View Source

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

assignment()

@type assignment() ::
  {owner :: node_id(), prev_owner :: node_id() | nil, row_epoch :: epoch()}

config()

@type config() :: map()

epoch()

@type epoch() :: non_neg_integer()

leader_term()

@type leader_term() :: pos_integer()

lease()

@type lease() :: %{node: node_id(), ttl_ms: pos_integer()}

node_id()

@type node_id() :: term()

status()

@type status() :: :joining | :active | :leaving

store()

@type store() :: GenServer.server()

vnode_id()

@type vnode_id() :: non_neg_integer()

Callbacks

acquire(store, node_id, ttl_ms)

@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.

cas_assign(store, vnode_id, node_id, epoch, leader_term)

@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.

cas_settle(store, vnode_id, epoch, leader_term)

@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.

ensure_config(store, config)

@callback ensure_config(store(), config()) :: {:ok, config()} | {:error, :unreachable}

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.

read_leases(store)

@callback read_leases(store()) :: {:ok, [node_id()]} | {:error, :unreachable}

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()).

read_members(store)

@callback read_members(store()) ::
  {:ok, [{node_id(), status()}], epoch()} | {:error, :unreachable}

read_table(store)

@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.

register(store, node_id, status)

@callback register(store(), node_id(), status()) ::
  {:ok, epoch()} | {:error, :unreachable | term()}

release(store, node_id)

@callback release(store(), node_id()) :: :ok | {:error, :unreachable}

Drop the lease. Idempotent.

renew(store, node_id, ttl_ms)

@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.

set_status(store, node_id, status)

@callback set_status(store(), node_id(), status()) ::
  {:ok, epoch()} | {:error, :unknown_node | :unreachable | term()}