Current section

Files

Jump to
super_cache lib cluster cluster_bootstrap.ex
Raw

lib/cluster/cluster_bootstrap.ex

defmodule SuperCache.Cluster.Bootstrap do
@moduledoc """
Cluster-aware startup and shutdown for SuperCache.
## Start options
All options accepted by `SuperCache.Bootstrap.start!/1` are valid here,
plus the following cluster-specific keys:
| Option | Type | Default | Description |
|----------------------|--------------------------|----------------|------------------------------------------|
| `:cluster` | atom | `:distributed` | `:local` or `:distributed` |
| `:replication_factor`| integer | `2` | Total copies (primary + replicas) |
| `:replication_mode` | atom | `:async` | `:async`, `:sync`, or `:strong` (3PC) |
| `:num_partition` | integer | scheduler count| Number of ETS partitions |
| `:table_type` | atom | `:set` | ETS table type |
## Node-source options (forwarded to `NodeMonitor`)
| Option | Type | Default | Description |
|----------------|--------------------------|---------|------------------------------------------------|
| `:nodes` | `[node()]` || Static peer list evaluated once at start-up. |
| `:nodes_mfa` | `{module, atom, [term]}` || Called at init and on every `:refresh_ms` tick.|
| `:refresh_ms` | pos_integer | `5_000` | MFA re-evaluation interval (ignored otherwise).|
When neither `:nodes` nor `:nodes_mfa` is supplied, `NodeMonitor` falls
back to watching **all** Erlang-connected nodes (legacy behaviour).
## Replication modes
- **`:async`** — fire-and-forget. Lowest latency; eventual consistency.
- **`:sync`** — synchronous delivery to all replicas before returning.
One extra RTT per write.
- **`:strong`** — three-phase commit via
`SuperCache.Cluster.ThreePhaseCommit`. Guarantees that either all
replicas apply a write or none do. Three extra RTTs per write.
## Start sequence
1. Validate options.
2. Write all options to `SuperCache.Config`.
3. Reconfigure `NodeMonitor` with the node-source opts (`:nodes`,
`:nodes_mfa`, `:refresh_ms`). This is done early so `Manager` sees
the correct managed set when it health-checks peers in later steps.
4. If other nodes are already live, verify that every structural config
key on this node matches the cluster. Raises `ArgumentError` on any
mismatch. **No ETS tables have been created at this point**, so a
rejection leaves the node in a completely clean state and `start!/1`
can be retried with corrected opts without hitting "table already exists".
5. Start `Partition` and `Storage` subsystems.
6. Start the `Buffer` write-buffer pool.
7. If `:replication_mode` is `:strong`, run crash-recovery via
`ThreePhaseCommit.recover/0` to resolve in-doubt transactions left
over from a previous crash.
8. If other nodes are already live, request a full sync so this node
receives a consistent snapshot of each partition.
9. Mark `:started` in config.
## Stop sequence
1. Stop the `Buffer` (flushes pending lazy writes).
2. Stop `Storage` (deletes ETS tables).
3. Stop `Partition` (clears partition registry).
4. Mark `:started` as `false`.
## Config verification
When a node joins a running cluster, `start!/1` calls `verify_cluster_config!/1`
which performs a pairwise comparison of every structural config key against all
live peers via `:erpc`. The keys checked are:
`[:key_pos, :partition_pos, :num_partition, :table_type, :replication_factor, :replication_mode]`
`:started`, `:cluster`, and `:table_prefix` are intentionally excluded:
`:started` is a liveness flag that will differ during bootstrap; `:cluster`
is always `:distributed` in this module; `:table_prefix` must already match
for ETS tables to be addressable so a mismatch would cause an earlier crash.
Any mismatch raises `ArgumentError` listing every divergent key with both
the local and remote values so the operator can identify the problem
immediately rather than observing silent data inconsistency later.
"""
require Logger
alias SuperCache.{Config, Partition, Storage, Buffer}
alias SuperCache.Cluster.{Manager, NodeMonitor, ThreePhaseCommit}
@default_opts [
key_pos: 0,
partition_pos: 0,
cluster: :distributed,
replication_factor: 2,
replication_mode: :async,
table_type: :set
]
@valid_table_types [:set, :ordered_set, :bag, :duplicate_bag]
@valid_rep_modes [:async, :sync, :strong]
# Keys forwarded verbatim to NodeMonitor.reconfigure/1.
# All three are optional; omitting them preserves NodeMonitor's current
# source (or its :all fallback on a fresh start).
@node_source_keys [:nodes, :nodes_mfa, :refresh_ms]
# Structural keys that MUST be identical on every cluster node.
@config_keys [
:key_pos,
:partition_pos,
:num_partition,
:table_type,
:replication_factor,
:replication_mode
]
# ── Public API ───────────────────────────────────────────────────────────────
@doc """
Start SuperCache in cluster mode with the given options.
Raises `ArgumentError` for invalid options or when the node's structural
config does not match an already-running cluster.
"""
@spec start!(keyword) :: :ok
def start!(opts \\ @default_opts) do
Logger.info("super_cache, cluster.bootstrap, starting with opts: #{inspect(opts)}")
validate!(opts)
Config.clear_config()
Enum.each(opts, fn {k, v} -> Config.set_config(k, v) end)
num_partition = resolve_num_partition()
resolve_table_type()
resolve_table_prefix()
resolve_replication_mode()
# Reconfigure NodeMonitor with the node-source opts supplied by the caller.
#
# Ordering rationale:
# NodeMonitor must know which peers to watch before Manager starts
# health-checking them. Reconfiguring here — after Config is written but
# before any subsystem starts — ensures the managed set is correct when
# Manager.live_nodes/0 is called in the verify and full-sync steps below.
#
# If the caller omits all node-source keys, node_source_opts/1 returns [],
# and NodeMonitor.reconfigure([]) falls back to :all mode (watches every
# Erlang-connected node), preserving legacy behaviour.
reconfigure_node_monitor(opts)
# Verify cluster config BEFORE starting any subsystem.
#
# Ordering rationale:
# verify_cluster_config!/1 only needs the config keys that were just
# written to SuperCache.Config — it performs no ETS operations and has
# no dependency on Partition, Storage, or Buffer.
#
# Moving verification here means that if the check fails (ArgumentError),
# no ETS tables have been created yet. The node is left in a clean state
# and start!/1 can be retried with corrected opts without hitting
# "table name already exists" from a prior partial start.
if Config.get_config(:cluster) == :distributed do
live = Manager.live_nodes() -- [node()]
if live != [] do
verify_cluster_config!(live)
end
end
Partition.start(num_partition)
Storage.start(num_partition)
Buffer.start(Partition.get_schedulers())
# Resolve any in-doubt 3PC transactions from a previous crash.
if Config.get_config(:replication_mode) == :strong do
Logger.info("super_cache, cluster.bootstrap, running 3PC crash recovery")
ThreePhaseCommit.recover()
end
# Pull a consistent snapshot from existing cluster members.
if Config.get_config(:cluster) == :distributed do
live = Manager.live_nodes() -- [node()]
if live != [] do
Logger.info(
"super_cache, cluster.bootstrap, requesting full sync from: #{inspect(live)}"
)
Manager.full_sync()
end
end
Config.set_config(:started, true)
# Pre-warm :erpc connections to all known peers to avoid first-call latency.
prewarm_peer_connections(opts)
Logger.info("super_cache, cluster.bootstrap, ready (#{num_partition} partitions)")
:ok
end
@doc """
Stop SuperCache and release all ETS resources.
"""
@spec stop() :: :ok
def stop() do
Buffer.stop()
case Config.get_config(:num_partition) do
nil -> :ok
n -> Storage.stop(n)
end
Partition.stop()
Config.set_config(:started, false)
:ok
end
@doc """
Returns `true` when this node is running in distributed mode and has
completed start-up.
Called remotely by `Manager.node_running?/1` via `:erpc`.
"""
@spec running?() :: boolean
def running?() do
Config.get_config(:cluster) == :distributed and
Config.get_config(:started, false) == true
end
@doc """
Return the structural config of this node as a map.
Called via `:erpc` from a joining node during config verification.
Returns only the keys in `@config_keys` — never liveness flags.
## Example
SuperCache.Cluster.Bootstrap.export_config()
# => %{key_pos: 0, partition_pos: 0, num_partition: 8,
# table_type: :set, replication_factor: 2, replication_mode: :async}
"""
@spec export_config() :: map
def export_config() do
Map.new(@config_keys, fn k -> {k, Config.get_config(k)} end)
end
@doc """
Return the full partition map for this node as a list of
`{partition_idx, {primary, replicas}}` pairs.
Called via `:erpc` from test helpers on the test node to read the
partition assignment of a remote peer without crossing the no-lambda
boundary. `num` must match `SuperCache.Config.get_config(:num_partition)`
on the calling node; callers should read that value locally and pass it
as an argument so the comparison is always against the same reference.
## Example
SuperCache.Cluster.Bootstrap.fetch_partition_map(8)
# => [{0, {:"a@host", [:"b@host"]}}, ...]
"""
@spec fetch_partition_map(pos_integer) :: [{non_neg_integer, {node, [node]}}]
def fetch_partition_map(num) do
Enum.map(0..(num - 1), fn idx ->
{idx, SuperCache.Cluster.Manager.get_replicas(idx)}
end)
end
# ── Private — NodeMonitor reconfiguration ────────────────────────────────────
# Extract the node-source keys the caller passed and forward them to
# NodeMonitor. If none were supplied, opts is [], which makes NodeMonitor
# fall back to :all mode — identical to the behaviour before this feature.
defp reconfigure_node_monitor(opts) do
node_opts = Keyword.take(opts, @node_source_keys)
Logger.info(
"super_cache, cluster.bootstrap, reconfiguring node_monitor with: #{inspect(node_opts)}"
)
NodeMonitor.reconfigure(node_opts)
end
# ── Private — validation ─────────────────────────────────────────────────────
defp validate!(opts) do
unless Keyword.keyword?(opts) do
raise ArgumentError, "options must be a keyword list"
end
for key <- [:key_pos, :partition_pos] do
unless Keyword.has_key?(opts, key) do
raise ArgumentError, "missing required option: #{inspect(key)}"
end
end
if mode = Keyword.get(opts, :replication_mode) do
unless mode in @valid_rep_modes do
raise ArgumentError,
"invalid :replication_mode #{inspect(mode)}, must be one of #{inspect(@valid_rep_modes)}"
end
end
# Validate node-source options eagerly so errors surface at the call site
# rather than inside NodeMonitor's GenServer init.
validate_node_source!(opts)
end
# Mirrors the resolve_source/1 guards in NodeMonitor so failures are raised
# in the caller's process with a clear Bootstrap context in the stack trace.
defp validate_node_source!(opts) do
has_nodes = Keyword.has_key?(opts, :nodes)
has_nodes_mfa = Keyword.has_key?(opts, :nodes_mfa)
if has_nodes and has_nodes_mfa do
raise ArgumentError, "only one of :nodes or :nodes_mfa may be specified, not both"
end
if has_nodes do
nodes = Keyword.fetch!(opts, :nodes)
unless is_list(nodes) and Enum.all?(nodes, &is_atom/1) do
raise ArgumentError, ":nodes must be a list of node atoms"
end
end
if has_nodes_mfa do
mfa = Keyword.fetch!(opts, :nodes_mfa)
unless match?({m, f, a} when is_atom(m) and is_atom(f) and is_list(a), mfa) do
raise ArgumentError, ":nodes_mfa must be a {module, function, args} tuple"
end
end
if refresh_ms = Keyword.get(opts, :refresh_ms) do
unless is_integer(refresh_ms) and refresh_ms > 0 do
raise ArgumentError, ":refresh_ms must be a positive integer"
end
end
end
# ── Private — cluster config verification ────────────────────────────────────
defp verify_cluster_config!(live_nodes) do
local_cfg = export_config()
Logger.info(
"super_cache, cluster.bootstrap, verifying config against #{length(live_nodes)} peer(s)"
)
mismatches =
live_nodes
|> Task.async_stream(
fn peer ->
case fetch_remote_config(peer) do
{:ok, remote_cfg} ->
diffs =
Enum.flat_map(@config_keys, fn key ->
local_val = Map.get(local_cfg, key)
remote_val = Map.get(remote_cfg, key)
if local_val == remote_val do
[]
else
[{key, local_val, remote_val}]
end
end)
{peer, diffs}
{:error, reason} ->
Logger.warning(
"super_cache, cluster.bootstrap, could not fetch config from " <>
"#{inspect(peer)}: #{inspect(reason)} — skipping peer"
)
{peer, :unreachable}
end
end,
timeout: 8_000,
on_timeout: :kill_task
)
|> Enum.flat_map(fn
{:ok, {_peer, :unreachable}} -> []
{:ok, {_peer, []}} -> []
{:ok, {peer, diffs}} -> [{peer, diffs}]
_ -> []
end)
if mismatches == [] do
Logger.info("super_cache, cluster.bootstrap, config verified — all peers agree")
else
details =
Enum.map_join(mismatches, "\n", fn {peer, diffs} ->
diff_lines =
Enum.map_join(diffs, "\n", fn {key, local_val, remote_val} ->
" #{inspect(key)}: local=#{inspect(local_val)}, peer=#{inspect(remote_val)}"
end)
" peer #{inspect(peer)}:\n#{diff_lines}"
end)
raise ArgumentError, """
SuperCache config mismatch — this node cannot join the cluster.
Every node must be started with identical structural configuration.
Mismatched keys:
#{details}
Fix: ensure start!/1 is called with the same opts on every node.
"""
end
end
defp fetch_remote_config(peer) do
try do
cfg = :erpc.call(peer, __MODULE__, :export_config, [], 5_000)
{:ok, cfg}
catch
kind, reason ->
{:error, {kind, reason}}
end
end
# ── Private — connection pre-warming ─────────────────────────────────────────
# Pre-warm :erpc connections to all known peers by making a lightweight call.
# This avoids the ~50ms connection setup penalty on the first cache operation.
defp prewarm_peer_connections(opts) do
peers =
case Keyword.get(opts, :nodes) do
nil -> []
nodes -> nodes
end
if peers != [] do
Logger.info(
"super_cache, bootstrap, pre-warming connections to #{length(peers)} peer(s)..."
)
peers
|> Enum.each(fn peer ->
try do
# Lightweight call to verify connectivity and establish :erpc channel.
:erpc.call(peer, :erlang, :node, [], 5_000)
Logger.debug("super_cache, bootstrap, pre-warmed connection to #{inspect(peer)}")
catch
kind, reason ->
Logger.warning(
"super_cache, bootstrap, failed to pre-warm connection to #{inspect(peer)}: " <>
inspect({kind, reason})
)
end
end)
end
end
# ── Private — config resolution ──────────────────────────────────────────────
defp resolve_num_partition() do
case Config.get_config(:num_partition, :not_found) do
:not_found ->
n = Partition.get_schedulers()
Config.set_config(:num_partition, n)
n
n ->
n
end
end
defp resolve_table_type() do
case Config.get_config(:table_type, :not_found) do
:not_found -> Config.set_config(:table_type, :set)
t when t in @valid_table_types -> :ok
bad -> raise ArgumentError, "unsupported table type: #{inspect(bad)}"
end
end
defp resolve_table_prefix() do
case Config.get_config(:table_prefix, :not_found) do
:not_found -> Config.set_config(:table_prefix, "SuperCache.Storage.Ets")
_ -> :ok
end
end
defp resolve_replication_mode() do
case Config.get_config(:replication_mode, :not_found) do
:not_found -> Config.set_config(:replication_mode, :async)
m when m in @valid_rep_modes -> :ok
bad -> raise ArgumentError, "unsupported replication_mode: #{inspect(bad)}"
end
end
end