Fief.Key behaviour (Fief v0.1.0)

Copy Markdown View Source

The sanctioned per-key-process layer (design §5.5, §8; implementation.md §7): hashing, the wrapper message format, the user-facing key behaviour, and the public key-addressed API. Built purely on the public Fief.Vnode behaviour and Fief.Router — the runtime half is Fief.Key.VnodeImpl, the per-key processes are Fief.Key.Server, and the ⊨-rule state machines it embeds are Fief.Transfer (the key-generic transfer substrate).

Runtime floor

The Key layer requires Erlang/OTP 28+: the transfer freeze advisory (handle_freeze/2) is delivered as an EEP 76 priority message, so it skips a flooded key's backlog. Fief.Key.VnodeImpl enforces this at instance boot. (Elixir's floor stays ~> 1.20; it has no OTP-version mechanism.)

Hashing (Decision, implementation.md §7)

The key→vnode map is rem(hasher.hash_key(key), partitions), where hasher is a pluggable Fief.Hasher module (the :hasher impl opt, defaulting to Fief.Hasher.Default:erlang.phash2/2 over binaries). The mapping is immutable per instance (design §9); a future algorithm is a new module, not a version integer. The default hasher accepts binaries only; a non-binary key raises unless the instance configures a custom :hasher — the same forced-explicit-canonicalization the old key_encoder contract gave.

A hasher is a module, never a fun, because the module atom is the fingerprint: Fief.Key.VnodeImpl's config_fingerprint returns {:hasher, module} — exactly and only what gates cross-node key→vnode agreement (sweep rates, pend bounds, blob caps are per-node tuning and deliberately absent). Behavioral stability of a custom hasher is the user's contract (Fief.Hasher).

The algorithm lives in Fief.Hasher — the neutral substrate shared with Fief.Cache, the other key-addressed surface.

Wrapper format (Decision)

Key identity is {key_module, key} — multiple use Fief.Key modules coexist in one instance, sharing the vnode space. The router payload is {:key, key_module, key, msg}; the hash covers the key alone (the module is identity, not placement). Only Fief.Key.VnodeImpl ever pattern matches this tuple — to the framework it is opaque payload.

Public surface (Decision)

Fief.Key.call(instance, key_module, key, msg, opts) / cast/4 / owner_of/2 / reply/2, plus use Fief.Key sugar on the key module: MyKey.call(instance, key, msg, opts), MyKey.cast(instance, key, msg). The vnode-level Fief.call/4 stays untouched — key hashing happens here, caller-side, reading the instance's immutable router config (partitions + the impl's hasher opt).

The key behaviour

defmodule MyApp.Session do
  use Fief.Key, on_fence: :terminate   # | :continue | :callback

  @impl true
  # Every incarnation starts here. source: :fresh | {:residual, blob}.
  # ctx.origin says why this incarnation exists (informational).
  def init(key, :fresh, _ctx), do: {:ok, load_or_create(key)}
  def init(_key, {:residual, blob}, _ctx), do: {:ok, deserialize(blob)}

  @impl true
  def handle_message(msg, from, state),
    do: {:reply, reply, state}                         # serve (routed)

  # optional; your own mailbox — timers, PubSub, monitors (NOT validated)
  @impl true
  def handle_info(:flush, state), do: {:noreply, flush(state)}

  # optional; freeze advisory (priority — skips your backlog). This
  # incarnation WILL stop: extract, your own return, or the deadline.
  @impl true
  def handle_freeze(_deadline_ms, state), do: {:noreply, start_bundling(state)}

  @impl true
  def extract_state(state), do: {:ok, blob}            # freeze

  # only consulted for on_fence: :callback
  @impl true
  def handle_fence(deadline_ms, state), do: {:stop, :fenced}
end

Key-process lifecycle in one line (implementation.md §7; superseded by docs/key-redesign.md): init (every incarnation — fresh, escheat, transfer, resume) → serve (handle_message routed + validated; handle_info your own mailbox, unvalidated) → handle_freeze advisory (optional) → extract_state + retire (freeze) | {:stop, …} | die-with-node.

The open mailbox (docs/key-redesign.md §2.2, §3.2)

A key is "a GenServer that fief places, moves, and fences": the framework claims its own reserved messages (the fief_key_-prefixed tags) and routes everything else to your optional handle_info/2 — your timers, PubSub, monitors, anything keyed to self(). Two facts bound what this buys you:

  • handle_message is validated; handle_info is not. Routed messages arrive only while this node owns the vnode under a live lease; your own ingress can fire while ownership is moving or gone (bounded by the fence kill under :terminate). Mutate state from handle_info only in ways that tolerate that, or route the event through cast/4 — which is also the only ingress that reaches a non-resident key (cold start on touch); a subscription only fires while you happen to be resident.
  • Process-scoped resources die with each incarnation. init/3 runs on every incarnation, so it is where you re-establish subscriptions/timers/ monitors — nothing rides the blob except what extract_state/1 put there. Do not register a key under a global name: the only address of a key is {key_module, key} through the routed API.

A single init/3 replaces the old init/2 + inject_state/2 + escheat/2 split: a fresh owner after donor death cannot distinguish "existed elsewhere before" from "never existed," so init was already necessarily the locate-durable-truth path. source is :fresh or {:residual, blob} (the blob is exactly an extract_state/1 product); ctx.origin (:first_touch | :escheat | :transfer | :resume) is informational, never procedural — correct code may branch on it for logging/metrics but must be correct ignoring it.

The two-phase freeze (docs/key-redesign.md §2.4, §4.2)

An open mailbox means a key can be flooded, and a naive freeze would then stall a transfer indefinitely (extract queues behind the flood; the donor never drains). Freeze is therefore two-phase, backstopped by a deadline:

  1. Advisoryhandle_freeze/2, a priority message (skips the backlog). Pure information: the user picks the policy (drain, bundle, ship-now, or die). Optional; dropped if not exported.
  2. Extract:fief_key_extract in normal mailbox order. Healthy keys quiesce exactly as before: in-flight validated callers are served, nobody times out.
  3. Deadline — a donor-side per-freeze timer (extract_deadline vnode opt, default 5s). At expiry every key that hasn't extracted is killed (:kill, untrappable) and converges through the existing died-before-extract path (no residual → the recipient escheats). The deadline is not optional — it is the only rung that handles a key stuck inside a callback, where no message priority can help.

Fence modes (design §8)

on_fence: is per key module. :terminate (default) — the unconditional kill handles it; the only mode under which guarantee 1 covers live processes and their side effects. :continue — the key's servers live under the instance's limbo supervision (Fief.Key.Limbo, outside the agent's link tree) from birth, so they survive the fence kill; stale survivors are reaped when a new impl incarnation for their vnode initializes. Forfeits guarantee 1 for side effects. :callback — the fence advisory fans handle_fence/2 out to the key's servers (also limbo-supervised, so a {:continue, state} answer survives the kill); {:stop, reason} stops. Advisory, under the framework's deadline — never load-bearing.

Summary

Types

The extract_state/1 product; a {:residual, blob} source hands it back verbatim. Mirrors Fief.Transfer.blob() — the transfer substrate's opaque per-key state term (Fief.Key depends on Fief.Transfer, never the reverse).

Callback context: %{instance:, node_id:, vnode_id:, origin:}.

A user key: a binary, or any term the instance's :hasher accepts.

Why this incarnation exists — informational, never procedural. :first_touch / :escheat pair with :fresh; :transfer (arrival via grant/push) / :resume (local rehydration after an aborted freeze) pair with {:residual, blob}.

What init/3 starts from: :fresh (no in-memory state survived — locate durable truth) or {:residual, blob} (an extract_state/1 product handed back on transfer arrival or local resume). {:residual, blob} rather than blob | nil so a legitimately-nil user blob stays unambiguous.

User key-process state. Opaque to fief.

Callbacks

Freeze: serialize the state for handover. Runs last; the process retires.

Only for on_fence: :callback: the lease is lost — flush, demote to read-only ({:continue, state}, survives the kill via limbo), or stop. deadline_ms is how long the advisory has before the fence completes; advisory, never load-bearing.

Optional. The transfer freeze advisory — a priority message (EEP 76), so it arrives ahead of any mailbox backlog. It means this incarnation will stop: via extract_state/1 (draining first), your own {:extract, _} / {:stop, _, _}, or the donor-side deadline kill (deadline_ms from now). There is no path back to serving. Returns

Optional. Everything in your mailbox that is not a framework-reserved message: your own timers, PubSub broadcasts, monitor :DOWNs — anything keyed to self(). Not ownership-validated (see the moduledoc): it fires whenever your process is alive, including while ownership is moving or gone. Absent, non-reserved messages are dropped silently.

Serve one message. from is opaque (nil for casts); {:reply, reply, state} answers the caller, {:noreply, state} leaves the caller to Fief.Vnode.reply/2 from user code or time out, {:stop, reason, state} retires the process after this message (the next message cold-starts).

Every incarnation starts here — fresh start, escheat, transfer arrival, or local resume. source is :fresh (locate durable truth) or {:residual, blob} (rehydrate from an extract_state/1 product); ctx.origin says why (informational). {:stop, reason} refuses the start.

Functions

Call key (owned by key_module) on instance and await the reply: {:ok, reply} | {:error, reason} — the Fief.Router.call/4 shape; opts takes :timeout, covering the whole :moved-retry loop. Runs in the caller. Raises ArgumentError for structural misuse (no vnode impl, a non-binary key the instance's :hasher rejects) — those are caller bugs, not runtime conditions.

Fire-and-forget to key (owned by key_module) on instance.

The presumed owner of key — hint-grade, like Fief.owner_of/2.

Answer a stashed from — a defdelegate to Fief.Vnode.reply/2, so a key answering a caller from a spawned task or a later incarnation (a from bundled into the blob at freeze) stays within the Fief.Key namespace. Location-transparent: works cross-node. Only meaningful for a from from a call (not a cast, whose from is nil).

The vnode key maps to on instance, per the instance's immutable hasher/partitions (read from the router config written once at instance boot): rem(hasher.hash_key(key), partitions).

Types

blob()

@type blob() :: Fief.Transfer.blob()

The extract_state/1 product; a {:residual, blob} source hands it back verbatim. Mirrors Fief.Transfer.blob() — the transfer substrate's opaque per-key state term (Fief.Key depends on Fief.Transfer, never the reverse).

ctx()

@type ctx() :: %{
  instance: atom(),
  node_id: term(),
  vnode_id: non_neg_integer(),
  origin: origin()
}

Callback context: %{instance:, node_id:, vnode_id:, origin:}.

key()

@type key() :: term()

A user key: a binary, or any term the instance's :hasher accepts.

origin()

@type origin() :: :first_touch | :escheat | :transfer | :resume

Why this incarnation exists — informational, never procedural. :first_touch / :escheat pair with :fresh; :transfer (arrival via grant/push) / :resume (local rehydration after an aborted freeze) pair with {:residual, blob}.

source()

@type source() :: :fresh | {:residual, blob()}

What init/3 starts from: :fresh (no in-memory state survived — locate durable truth) or {:residual, blob} (an extract_state/1 product handed back on transfer arrival or local resume). {:residual, blob} rather than blob | nil so a legitimately-nil user blob stays unambiguous.

state()

@type state() :: term()

User key-process state. Opaque to fief.

Callbacks

extract_state(state)

@callback extract_state(state()) :: {:ok, blob()}

Freeze: serialize the state for handover. Runs last; the process retires.

handle_fence(deadline_ms, state)

(optional)
@callback handle_fence(deadline_ms :: non_neg_integer(), state()) ::
  {:stop, term()} | {:continue, state()}

Only for on_fence: :callback: the lease is lost — flush, demote to read-only ({:continue, state}, survives the kill via limbo), or stop. deadline_ms is how long the advisory has before the fence completes; advisory, never load-bearing.

handle_freeze(deadline_ms, state)

(optional)
@callback handle_freeze(deadline_ms :: non_neg_integer(), state()) ::
  {:noreply, state()} | {:extract, state()} | {:stop, term(), state()}

Optional. The transfer freeze advisory — a priority message (EEP 76), so it arrives ahead of any mailbox backlog. It means this incarnation will stop: via extract_state/1 (draining first), your own {:extract, _} / {:stop, _, _}, or the donor-side deadline kill (deadline_ms from now). There is no path back to serving. Returns:

  • {:noreply, state} — keep draining; extract_state/1 arrives in mailbox order. The idiomatic bundling response flips a flag here and cheaply buffers {msg, from} pairs in handle_message thereafter (bounded by max_blob_size — an over-cap residual is dropped and the key escheats).
  • {:extract, state} — ship this state now: the server runs extract_state/1 immediately and stops; the mailbox backlog dies with the process (callers time out). For "my backlog is worthless but my in-memory state is not yet durable."
  • {:stop, reason, state} — die now, no residual; the recipient escheats from durable truth. For "durable truth is current; no transfer needed."

Dropped without calling user code if not exported.

handle_info(msg, state)

(optional)
@callback handle_info(msg :: term(), state()) ::
  {:noreply, state()} | {:stop, term(), state()}

Optional. Everything in your mailbox that is not a framework-reserved message: your own timers, PubSub broadcasts, monitor :DOWNs — anything keyed to self(). Not ownership-validated (see the moduledoc): it fires whenever your process is alive, including while ownership is moving or gone. Absent, non-reserved messages are dropped silently.

handle_message(msg, from, state)

@callback handle_message(msg :: term(), from :: term() | nil, state()) ::
  {:reply, term(), state()} | {:noreply, state()} | {:stop, term(), state()}

Serve one message. from is opaque (nil for casts); {:reply, reply, state} answers the caller, {:noreply, state} leaves the caller to Fief.Vnode.reply/2 from user code or time out, {:stop, reason, state} retires the process after this message (the next message cold-starts).

init(key, source, ctx)

@callback init(key(), source(), ctx()) :: {:ok, state()} | {:stop, term()}

Every incarnation starts here — fresh start, escheat, transfer arrival, or local resume. source is :fresh (locate durable truth) or {:residual, blob} (rehydrate from an extract_state/1 product); ctx.origin says why (informational). {:stop, reason} refuses the start.

Functions

call(instance, key_module, key, msg, opts \\ [])

@spec call(atom(), module(), key(), term(), keyword()) ::
  {:ok, term()} | {:error, Fief.Router.call_error()}

Call key (owned by key_module) on instance and await the reply: {:ok, reply} | {:error, reason} — the Fief.Router.call/4 shape; opts takes :timeout, covering the whole :moved-retry loop. Runs in the caller. Raises ArgumentError for structural misuse (no vnode impl, a non-binary key the instance's :hasher rejects) — those are caller bugs, not runtime conditions.

cast(instance, key_module, key, msg)

@spec cast(atom(), module(), key(), term()) ::
  :ok | {:error, Fief.Router.call_error()}

Fire-and-forget to key (owned by key_module) on instance.

owner_of(instance, key)

@spec owner_of(atom(), key()) ::
  {:ok, term()} | {:error, :no_owner | :unreachable | :not_running}

The presumed owner of key — hint-grade, like Fief.owner_of/2.

reply(from, reply)

@spec reply(term(), term()) :: :ok

Answer a stashed from — a defdelegate to Fief.Vnode.reply/2, so a key answering a caller from a spawned task or a later incarnation (a from bundled into the blob at freeze) stays within the Fief.Key namespace. Location-transparent: works cross-node. Only meaningful for a from from a call (not a cast, whose from is nil).

vnode!(instance, key)

@spec vnode!(atom(), key()) :: non_neg_integer()

The vnode key maps to on instance, per the instance's immutable hasher/partitions (read from the router config written once at instance boot): rem(hasher.hash_key(key), partitions).