View Source ProcessHub.Service.Recovery (ProcessHub v0.6.0)
State-machine logic for the coordinator boot-recovery lifecycle.
Experimental
Coordinator boot recovery is experimental and may change in future releases. Use in production at your own discretion.
When :auto_recovery is enabled the coordinator transitions through
:recovering → :normal on start-up, driven by the marker gate: the
mode is resolved from the marker at init/1. Marker present → straight
to :normal. Marker absent → :recovering with a cspecs-only replay,
then :normal. While the gate is closed cluster events are queued and
drained in FIFO order on gate open. The marker is rewritten on every
successful boot.
Replay only produces work with a persistent :registry_backend (e.g.
{:dets, _}); with :ets the dump is empty. Replay is cspecs-only:
node_pids and metadata are not restored — bindings are recomputed
by the first migration tick after the cluster forms.
This module owns the pure helpers used by the coordinator:
- parsing/validating the
:auto_recoveryconfig (including:marker_path) - resolving the recovery mode (
resolve_mode/2) from the marker - marker IO (
marker_exists?/1,write_marker/1,delete_marker/1) - replaying the persisted registry into
Distributor.compose_start_operation/3(best-effort, partial-success tolerant) - emitting
[:process_hub, :recovery, _]telemetry and dispatching the recovery hooks
The coordinator stays the GenServer; this module is stateless aside from the data passed in.
Summary
Types
Result of a recovery-replay run.
Functions
Blocks until the coordinator reaches :normal or timeout_ms elapses.
Reports the cspec_count currently persisted, used for telemetry
measurements at recovery boot.
Deletes the marker file at path. Returns :ok if the marker is
absent (no-op). Returns {:error, posix()} on permission/IO failure.
Returns the disabled (default) config.
Dispatches a hook synchronously, awaiting each handler's reply. Each
handler is wrapped in try/catch so a misbehaving handler can neither
crash the coordinator nor (via the per-handler timeout) hang it past
the overall recovery_timeout_ms.
Dispatches the recovery_state_changed hook.
Appends a deferred cluster event to the queue.
Returns true when cluster events must be deferred (gate closed).
Builds the marker config for a hub: resolves the absolute path and
computes the initial recovery_state from the marker + filesystem.
Returns whether the marker file at path exists.
Injects recovery_replay: bool into backend opts when :auto_recovery
is enabled. Honours an explicitly set value if the caller already
provided one.
Opens the recovery gate, dispatches the transition hook, and drains queued events.
Parses the :auto_recovery config field into a normalized map.
Deletes the recovery marker on the local node so the next coordinator boot selects recovery mode.
Fans out prepare_recovery/1 to every member of the hub via
:rpc.multicall/4.
Returns the coordinator's current :recovery_state.
Runs the persisted-registry replay synchronously inside the calling process (the coordinator).
Builds the recovering → :normal hook measurements from a replay result.
Resolves the absolute marker path for a hub.
Resolves the effective recovery mode at coordinator init from the marker.
Runs the replay in a separate process; replies with {:marker_replay_done, result}.
Drives the marker-gated lifecycle from coordinator init.
Builds the recovering → :normal hook measurements for the timeout ceiling.
Writes a zero-byte marker file at path, creating parent directories.
Idempotent on success; returns {:error, posix()} on IO failure.
Types
@type replay_result() :: %{ child_count: non_neg_integer(), succeeded: non_neg_integer(), failed: non_neg_integer(), skipped: non_neg_integer(), attempted: non_neg_integer(), elapsed_ms: non_neg_integer(), reason: :completed | :replay_timeout | :empty }
Result of a recovery-replay run.
Functions
@spec await_normal(ProcessHub.hub_id(), non_neg_integer()) :: :ok | {:error, :timeout}
Blocks until the coordinator reaches :normal or timeout_ms elapses.
Returns :ok on reaching :normal (immediately when the hub does not exist
or has no recovery), or {:error, :timeout} otherwise.
@spec cspec_count(ProcessHub.hub_id()) :: non_neg_integer()
Reports the cspec_count currently persisted, used for telemetry
measurements at recovery boot.
Deletes the marker file at path. Returns :ok if the marker is
absent (no-op). Returns {:error, posix()} on permission/IO failure.
@spec disabled_config() :: ProcessHub.Hub.recovery_config()
Returns the disabled (default) config.
dispatch_blocking_hook(hook_table, hook_key, hook_data, total_timeout_ms)
View Source@spec dispatch_blocking_hook( :ets.tid(), ProcessHub.Service.HookManager.hook_key(), term(), pos_integer() ) :: :ok
Dispatches a hook synchronously, awaiting each handler's reply. Each
handler is wrapped in try/catch so a misbehaving handler can neither
crash the coordinator nor (via the per-handler timeout) hang it past
the overall recovery_timeout_ms.
Handlers are executed in registered order; per-handler timeouts are computed as the remaining slice of the total budget. A handler raising is logged at WARN and the dispatch continues to the next handler.
dispatch_state_changed_hook(state, from, to, reason, measurements \\ %{})
View Source@spec dispatch_state_changed_hook(ProcessHub.Hub.t(), atom(), atom(), atom(), map()) :: :ok
Dispatches the recovery_state_changed hook.
Fires on every lifecycle moment: :init → :recovering (:marker_absent),
:init → :normal (:marker_present/:disabled), and :recovering → :normal
(:replay_complete/:recovery_timeout). measurements carries the
per-moment counts (e.g. cspec_count, succeeded, elapsed_ms).
@spec enqueue(ProcessHub.Hub.t(), term()) :: ProcessHub.Hub.t()
Appends a deferred cluster event to the queue.
@spec gate_closed?(ProcessHub.Hub.t()) :: boolean()
Returns true when cluster events must be deferred (gate closed).
@spec init_marker(atom(), nil | String.t(), boolean()) :: {ProcessHub.Hub.recovery_marker(), ProcessHub.Hub.recovery_state(), :normal | :recovery}
Builds the marker config for a hub: resolves the absolute path and
computes the initial recovery_state from the marker + filesystem.
Returns {marker, initial_state, resolved_mode}. The caller (the
coordinator's init/1) stamps the marker onto %Hub{} and decides
what to do next via start/2.
Returns whether the marker file at path exists.
nil paths and unreadable parents return false (selecting recovery
mode is the safe direction).
Injects recovery_replay: bool into backend opts when :auto_recovery
is enabled. Honours an explicitly set value if the caller already
provided one.
@spec open_gate(ProcessHub.Hub.t(), atom(), map()) :: ProcessHub.Hub.t()
Opens the recovery gate, dispatches the transition hook, and drains queued events.
@spec parse_config(false | true | keyword() | term()) :: {:ok, ProcessHub.Hub.recovery_config()} | {:error, :invalid_auto_recovery | {:invalid_auto_recovery, atom()}}
Parses the :auto_recovery config field into a normalized map.
Accepts the documented shapes:
false— disabled.true— enabled with defaults.keyword()— explicit:recovery_timeout_msand the optional:marker_pathoverride.
Returns {:ok, recovery_config} or {:error, reason} for out-of-range
values. Unknown shapes return {:error, :invalid_auto_recovery} so the
caller can decide whether to fall back to disabled or to refuse to
start.
@spec prepare_recovery(ProcessHub.hub_id()) :: :ok | {:error, term()}
Deletes the recovery marker on the local node so the next coordinator boot selects recovery mode.
Safe to call on a running hub — only the marker file is touched.
Idempotent (returns :ok even when the marker is absent). Hubs started
without :auto_recovery are no-ops.
See prepare_recovery_cluster/1 for the RPC fan-out variant.
@spec prepare_recovery_cluster(ProcessHub.hub_id()) :: {:ok, [node()]} | {:partial, [node()], [node()]} | {:error, term()}
Fans out prepare_recovery/1 to every member of the hub via
:rpc.multicall/4.
Returns:
{:ok, [node]}— every member acked.{:partial, [acked], [unreachable]}— at least one peer failed.{:error, reason}— cluster API itself failed (hub not running).
@spec recovery_state(ProcessHub.hub_id()) :: :recovering | :normal
Returns the coordinator's current :recovery_state.
Returns :normal when the hub does not exist or was started without
:auto_recovery.
@spec replay(ProcessHub.Hub.t(), ProcessHub.Hub.recovery_config()) :: replay_result()
Runs the persisted-registry replay synchronously inside the calling process (the coordinator).
Sequence:
- dispatch the
pre_recovery_replayhook synchronously (blocking), - iterate
ProcessRegistry.dump/1, - call
Distributor.compose_start_operation/3once with all child specs, - wait up to
recovery_timeout_msfor completion (best effort), - dispatch
post_recovery_replay(async).
Returns a replay_result/0 summary that the coordinator uses to update
state and dispatch the recovery_state_changed hook.
Per-child failures during replay are logged at WARN and surface in the
failed count; they never abort the replay loop. If recovery_timeout_ms
elapses before completion, the function returns with
reason: :replay_timeout. Replay continues in the background.
Builds the recovering → :normal hook measurements from a replay result.
Resolves the absolute marker path for a hub.
If path is non-nil, returns it as-is. Otherwise resolves to
<:filename.basedir(:user_data, "process_hub")>/<hub_id>/cluster.healthy.
Resolves the effective recovery mode at coordinator init from the marker.
marker_enabled?isfalse→:normalmarker_exists?istrue→:normal(trust peers)- otherwise →
:recovery(rebuild from disk)
@spec spawn_replay(ProcessHub.Hub.t()) :: ProcessHub.Hub.t()
Runs the replay in a separate process; replies with {:marker_replay_done, result}.
@spec start(ProcessHub.Hub.t(), :normal | :recovery) :: ProcessHub.Hub.t()
Drives the marker-gated lifecycle from coordinator init.
- marker disabled → no-op.
- mode
:normal→ emit:skipped, write the marker. - mode
:recovery→ emit:started, schedule the timeout, and sendself() :start_marker_replayso the replay runs after init returns.
Returns the updated state.
@spec timeout_measurements(ProcessHub.Hub.t()) :: map()
Builds the recovering → :normal hook measurements for the timeout ceiling.
Writes a zero-byte marker file at path, creating parent directories.
Idempotent on success; returns {:error, posix()} on IO failure.