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_recovery config (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.

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

Link to this function

await_normal(hub_id, timeout_ms \\ 60000)

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

@spec delete_marker(nil | String.t()) :: :ok | {:error, term()}

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.

Link to this function

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.

Link to this function

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

Link to this function

init_marker(hub_id, marker_path, auto_recovery_enabled?)

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

@spec marker_exists?(nil | String.t()) :: boolean()

Returns whether the marker file at path exists.

nil paths and unreadable parents return false (selecting recovery mode is the safe direction).

Link to this function

maybe_inject_replay_flag(opts, hub_id, hub_conf)

View Source
@spec maybe_inject_replay_flag(keyword(), atom(), map() | struct()) :: keyword()

Injects recovery_replay: bool into backend opts when :auto_recovery is enabled. Honours an explicitly set value if the caller already provided one.

Link to this function

open_gate(state, reason, measurements \\ %{})

View Source
@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_ms and the optional :marker_path override.

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.

Link to this function

prepare_recovery(hub_id)

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

Link to this function

prepare_recovery_cluster(hub_id)

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

Runs the persisted-registry replay synchronously inside the calling process (the coordinator).

Sequence:

  1. dispatch the pre_recovery_replay hook synchronously (blocking),
  2. iterate ProcessRegistry.dump/1,
  3. call Distributor.compose_start_operation/3 once with all child specs,
  4. wait up to recovery_timeout_ms for completion (best effort),
  5. 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.

Link to this function

replay_measurements(result)

View Source
@spec replay_measurements(map()) :: map()

Builds the recovering → :normal hook measurements from a replay result.

Link to this function

resolve_marker_path(hub_id, path)

View Source
@spec resolve_marker_path(atom(), nil | String.t()) :: String.t()

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.

Link to this function

resolve_mode(marker_exists?, marker_enabled?)

View Source
@spec resolve_mode(boolean(), boolean()) :: :normal | :recovery

Resolves the effective recovery mode at coordinator init from the marker.

  • marker_enabled? is false:normal
  • marker_exists? is true: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 send self() :start_marker_replay so the replay runs after init returns.

Returns the updated state.

Link to this function

timeout_measurements(state)

View Source
@spec timeout_measurements(ProcessHub.Hub.t()) :: map()

Builds the recovering → :normal hook measurements for the timeout ceiling.

@spec write_marker(nil | String.t()) :: :ok | {:error, term()}

Writes a zero-byte marker file at path, creating parent directories. Idempotent on success; returns {:error, posix()} on IO failure.