Fief.Planner (Fief v0.1.0)

Copy Markdown View Source

The leader-only fenced rebalance loop (design §5.3, §6.1–6.4; implementation.md §2). One instance of this process exists per namespace at a time — on whichever node holds leadership — started and stopped by Fief.Planner.Elector on :elected/:deposed. Every write is cas_assign/cas_settle carrying the planner's term, so a zombie planner (deposed but not yet notified) is fenced by the StateStore's term ratchet; duplicate planners can thrash a plan but never corrupt state.

State is derived, never handed over

The planner keeps no plan state worth surviving it: every pass re-reads membership, live leases, and the table from the Authority and recomputes. A successor leader resumes mid-rebalance by construction — open transfers are prev_owner rows, pacing is the "planner SELECT" (open-row count), and the settle trigger re-arrives via the settle relay's retry (Elector).

Liveness source

Serving membership = status :active and a live lease per Fief.StateStore.read_leases/1 — the arbiter's own judgement of expiry, the only thing reassignment may be gated on (design §6.4). Presence is hint-grade: a {:fief_presence, ...} message only runs a pass sooner.

Transfer rules (design §6.3, specs/FiefTransfer.tla)

  • Graceful move (MoveGraceful): one cas_assign, opening a transfer. At most max_concurrent_transfers transfers open at once, counted from the table (open prev_owner rows), so pacing survives leader failover.
  • Never chain (single-hop rule, FiefTransfer_chained): a vnode with an open transfer whose donor and recipient are healthy is never touched, whatever the desired distribution says. (Fief.Authority.Local additionally keeps the original donor if a chained cas_assign ever slipped through — M1's backstop — but the planner is the enforcement.)
  • Settle (Settle): only on the donor's {:fief_settle_report, session, node}, verified against the current row before the CAS (⊨ rule: settle is donor-reported).
  • Recipient death mid-transfer (ReassignToDonor): reassign back to the donor, never a third node — as the CAS pair assign + settle (the assign yields the {donor, donor} self-row, and no donor report exists for a self-session, so the planner settles it immediately; agents treat the settled row as handoff_in(nil) — the taint path).
  • Donor death mid-transfer (AbortSession): settle without a report — design §6.3: "the planner aborts the session (prev_owner → NULL) and unmigrated keys recover via escheat". This is the one settle not gated on a donor report, sanctioned by the spec as a distinct action.
  • Dead owner, no live donor (ReassignDead): assign anywhere healthy with no donor — expressed as the CAS pair assign + settle (the StateStore records the old owner as prev on any reassignment; the immediate settle clears it, so nothing ever pulls from a dead hop — the single-hop rule).
  • Drain for leave (design §6.2): :leaving members get zero desired vnodes; their vnodes move away as ordinary graceful transfers, and Fief.Node's leave gate holds the node in :leaving until it is neither owner nor prev_owner anywhere.

Arbiter loss

{:error, :unreachable} from any read or CAS freezes the pass: the planner stops writing (it could not CAS anyway) and retries at the next cadence tick — the on_arbiter_loss: :freeze posture, and the only planner-side meaning of that option (:serve_last_epoch is node-side: nodes already serve their last-observed epoch until they self-fence).

Stale writes

{:error, :stale} aborts the pass and schedules an immediate (0 ms seam timer) re-read-and-recompute — never a blind retry. Three consecutive stale passes mean the term itself is fenced (a newer leader ratcheted past us): the planner emits the :planner_stalled checkpoint and falls back to the cadence tick, waiting for the Elector's :deposed.

Registered under Fief.Seam.planner_name/1 (minted in this — sim bound — process), the cross-node address settle reports are relayed to. Follows THE process discipline (sim bind, :handled checkpoints, no raw timers, stale-timer catch-all).

Summary

Functions

Returns a specification to start this module under a supervisor.

Compute one pass's CAS actions from an authoritative snapshot. Pure and deterministic given the same inputs — the property leader failover leans on.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

plan(table, members, live, partitions, max_transfers)

@spec plan(map(), [{term(), atom()}], [term()], pos_integer(), pos_integer()) :: [
  {:assign, non_neg_integer(), term()} | {:settle, non_neg_integer()}
]

Compute one pass's CAS actions from an authoritative snapshot. Pure and deterministic given the same inputs — the property leader failover leans on.

Strategy (capacity-capped keep-first greedy, documented as the as-built decision): serving members (status :active, lease live) are sorted; each gets a capacity of partitions ÷ n (+1 for the first rem members). In vnode order, a settled vnode keeps its owner while the owner is serving and under capacity — minimal movement — and every other vnode (unassigned, dead owner, leaving owner, over-capacity owner) is filled onto the first serving member with spare capacity. Recovery actions (dead recipients/donors) run first and never consume transfer budget; graceful moves are capped by max_transfers minus the transfers still open after recovery.

Returns [{:assign, vnode, owner} | {:settle, vnode}], execution-ordered.

start_link(opts)