StatifierPersistence.Runs (StatifierPersistence v0.3.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).

An event step/5 can only build once the run's position is loaded.

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

event_builder()

@type event_builder() :: (Statifier.MachineState.t() ->
                      {:ok, Statifier.Event.t()} | :discard)

An event step/5 can only build once the run's position is loaded.

Called with the loaded, re-stamped Statifier.MachineState.t/0, inside the serialization strategy's with_run/3 and before Statifier.Interpreter.handle_event/2 - so what it reads and what the step acts on are the same position under the same exclusion. {:ok, event} steps that event; :discard steps nothing and returns {:discarded, run}.

It exists for events whose right to be delivered at all is a property of the position: an invocation's late answer, which spec 6.4.3 discards when the invocation is no longer live (StatifierPersistence.Driver's done_invocation/5). This adds no step to ADR-0004 decision 3's order - the event argument is late-bound, the loop is not re-ordered.

opt()

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

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.
  • metadata: (create/4 only) - the optional opaque map of host identities stored beside the run record (ADR-0006 decision 1), defaulting to %{}. Identities only, never personal data (decision 2); an adapter that cannot store a non-empty map refuses the create with {:error, :metadata_unsupported} (decision 3).
  • 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.

metadata: rides through to the inserted run record unchanged (ADR-0006 decision 1). Create is the only place it is set - step/5 and fail/4 carry the stored map forward and take no metadata: of their own - and an adapter that cannot store a non-empty map refuses here, before any effect is executed: {:error, :metadata_unsupported}.

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() | event_builder(),
  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.

event may also be a event_builder/0 - a fun the loaded position is handed, for an event only the position can build or decline. A builder that declines discards the delivery through the same {:discarded, run} arm.