StatifierPersistence.Runs (StatifierPersistence v0.1.0)

Copy Markdown View Source

The run lifecycle: create and step durable runs with no live Session process, the loop this package exists to package.

A step runs in ADR-0004 decision 3's order, and the order is the contract: liveness check on the run record -> load (guarded) -> re-stamp routes/invoke_types unconditionally (with the nil tripwire from st-ADR-0064: the fields are pattern-matched nil before stamping, so an upstream regression fails loudly here, not silently downstream) -> step via Interpreter.handle_event/2 -> execute effects via the executor seam -> consume :done and :budget_exhausted into run status -> assert MachineState.internal_queue_empty?/1 -> persist.

Effect execution is at-least-once: a crash between step and persist re-drives the same event and re-emits the same effects with identical deterministic keys (st-ADR-0054 decision 3, st-ADR-0059), and this loop never dedupes - idempotency is the consumer's. :done is the only path to :completed (ADR-0004 decision 6); an event delivered to a terminal run is discarded with a typed {:discarded, run} result, never an exception and never a silent step.

Executor failures on actionable effects re-enter the chart as error.communication events through Statifier.Interpreter.deliver_internal/5 (st-ADR-0039's seam), per st-ADR-0051's failed-communication row: the core alone mints the planning-time execution-error events, before any effect is emitted, so every failure an executor can report re-enters uniformly as error.communication (ADR-0004 decision 4). Failures on observational effects are discarded. Re-entry is single-wave per step: effects the re-entries emit are executed too, but their failures are not re-entered again, so a deterministically failing executor cannot loop this library.

Concurrent deliveries to one run are ordered by a pluggable per-run serialization strategy (ADR-0004 decision 5): every entry point runs its fetch-to-persist tail inside the strategy's StatifierPersistence.Serialization.with_run/3, selected per call with serialization: {module, config} and defaulting to {StatifierPersistence.Serialization.AdapterLock, store} - the adapter's own optional lock_run/3. A strategy refusal surfaces unchanged as {:error, {:serialization, reason}}.

Summary

Types

This module's error vocabulary: the facade's arms, unflattened, plus the {:budget_exhausted, payload} arm returned after a budget-exhausted step or create has persisted its :failed run record, plus the serialization strategy's own refusal, surfaced unchanged ({:serialization, :not_supported} from the default strategy over an adapter with no lock_run/3).

Options create/4 and step/5 accept

A run's caller-supplied opaque key (ADR-0004 decision 2).

Functions

Creates a run: Statifier.Interpreter.initialize/2 (which cannot fail), then the shared persist tail - effects through the executor seam, :done/:budget_exhausted consumed into run status, quiescence asserted, the record inserted with its encoded position.

Abandons a run: the only host-driven terminal transition (ADR-0004 decision 6). No interpreter is involved - abandonment is a host decision about the run, not a chart transition - so the stored position is left untouched and only the record's status and failure reason change.

Delivers one external event to a run, in ADR-0004 decision 3's order (the moduledoc quotes it).

Types

error()

@type error() ::
  StatifierPersistence.Storage.error()
  | {:budget_exhausted, Statifier.Effect.BudgetExhausted.t()}
  | {:serialization, term()}

This module's error vocabulary: the facade's arms, unflattened, plus the {:budget_exhausted, payload} arm returned after a budget-exhausted step or create has persisted its :failed run record, plus the serialization strategy's own refusal, surfaced unchanged ({:serialization, :not_supported} from the default strategy over an adapter with no lock_run/3).

opt()

@type opt() ::
  {:executor, StatifierPersistence.Executor.t()}
  | {:routes, Statifier.MachineState.routes()}
  | {:invoke_types, Statifier.MachineState.invoke_types()}
  | {:initialize, keyword()}
  | {:serialization, {module(), term()}}

Options create/4 and step/5 accept:

  • executor: (required) - the StatifierPersistence.Executor.t/0 every non-lifecycle effect is handed to, in list order.
  • routes: - the Statifier.Send.Routes.t/0 snapshot stamped onto the loaded position before the step; host-supplied per call, never read back from storage (st-ADR-0048). Defaults to nil, "no determination made".
  • invoke_types: - the Statifier.Invoke.Types.t/0 snapshot, stamped the same way (st-ADR-0051). Defaults to nil, "the built-in set only".
  • initialize: (create/4 only) - passed to Statifier.Interpreter.initialize/2 unchanged.
  • serialization: - the {module, config} per-run serialization strategy the fetch-to-persist tail runs inside (ADR-0004 decision 5; fail/4 accepts it too). Defaults to {StatifierPersistence.Serialization.AdapterLock, store}.

run_id()

A run's caller-supplied opaque key (ADR-0004 decision 2).

Functions

create(store, run_id, machine, opts)

@spec create(
  store :: StatifierPersistence.Storage.t(),
  run_id :: run_id(),
  machine :: Statifier.Machine.t(),
  opts :: [opt()]
) ::
  {:ok, StatifierPersistence.Run.t(), Statifier.MachineState.t()}
  | {:error, error()}

Creates a run: Statifier.Interpreter.initialize/2 (which cannot fail), then the shared persist tail - effects through the executor seam, :done/:budget_exhausted consumed into run status, quiescence asserted, the record inserted with its encoded position.

Create-exactly-once rests on the adapter's atomic :run_exists refusal (ADR-0004 decision 2), not on a pre-check here: creating an existing run_id returns {:error, :run_exists}.

A create whose initialize/2 exhausts its macrostep budget persists a :failed run with no position blob (there is no quiescent position to store - ADR-0004 decision 1) and then returns {:error, {:budget_exhausted, payload}}, so the caller sees both the durable state and the reason.

fail(store, run_id, reason, opts \\ [])

@spec fail(
  store :: StatifierPersistence.Storage.t(),
  run_id :: run_id(),
  reason :: String.t(),
  opts :: keyword()
) ::
  {:ok, StatifierPersistence.Run.t()}
  | {:discarded, StatifierPersistence.Run.t()}
  | {:error, error()}

Abandons a run: the only host-driven terminal transition (ADR-0004 decision 6). No interpreter is involved - abandonment is a host decision about the run, not a chart transition - so the stored position is left untouched and only the record's status and failure reason change.

A terminal run is discarded, same as step/5: {:discarded, run}. reason is the short string stored as the run's failure - keep it a prefixed, console-readable reason, not an inspect dump.

opts accepts serialization: only - the same {module, config} strategy create/4 and step/5 take, with the same default.

step(store, run_id, machine, event, opts)

@spec step(
  store :: StatifierPersistence.Storage.t(),
  run_id :: run_id(),
  machine :: Statifier.Machine.t(),
  event :: Statifier.Event.t(),
  opts :: [opt()]
) ::
  {:ok, StatifierPersistence.Run.t(), Statifier.MachineState.t()}
  | {:discarded, StatifierPersistence.Run.t()}
  | {:error, error()}

Delivers one external event to a run, in ADR-0004 decision 3's order (the moduledoc quotes it).

An event delivered to a terminal run returns {:discarded, run} from the run record alone, before any position decode. handle_event/2's {:error, :not_running} arm is the structural backstop for a run record whose :active status lies about a terminal stored position: it discards too, and repairs the record's status to :completed on the way out.