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

The fixed vocabulary of public doors entry names on this package's own telemetry (docs/telemetry.md). It is the dimension an operator slices step latency by first, because a :done_invocation step and a :step step have different expected shapes.

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

Cancels a run: the second host-driven terminal transition (ADR-0004 decision 6 as extended by ADR-0008 decision 5), and the one a cascading cancel writes through.

Cancels every run linked to parent_run_id - for one invocation, or for all of them - and every run linked to those, recursively (ADR-0008 decision 5).

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

entry()

@type entry() ::
  :create
  | :step
  | :done_invocation
  | :failed_invocation
  | :answer_parent
  | :fail
  | :cancel

The fixed vocabulary of public doors entry names on this package's own telemetry (docs/telemetry.md). It is the dimension an operator slices step latency by first, because a :done_invocation step and a :step step have different expected shapes.

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()}
  | {:linkage, StatifierPersistence.Run.Linkage.t()}
  | {:entry, entry()}

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}.
  • entry: - this package's own, never a host's, and telemetry-only: the public door this drive came through, carried on [:statifier_persistence, :run, :step, :start | :stop] and [:statifier_persistence, :run, :discarded] as entry (ADR-0009, docs/telemetry.md). StatifierPersistence.Driver sets it to :done_invocation, :failed_invocation or :answer_parent on the doors that reach step/5 rather than being one of its own; every other entry point derives its own (:create, :step, :fail, :cancel) and this option changes nothing but the reported value.
  • linkage: (create/4 only) - this package's own, never a host's. Set by the durable subchart start_child clause (Phase 3) to record a child's parent under the reserved metadata namespace (StatifierPersistence.Run.Linkage, ADR-0008 decision 2). A host supplies metadata: for its own identities; supplying linkage: from outside this package is a caller bug the same way a malformed metadata: is.

run_id()

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

Functions

cancel(store, run_id, opts \\ [])

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

Cancels a run: the second host-driven terminal transition (ADR-0004 decision 6 as extended by ADR-0008 decision 5), and the one a cascading cancel writes through.

Cancellation retains: no record and no position is deleted, no interpreter is involved, and the stored position is left untouched - only the record's status changes, to :cancelled. A run that is already terminal - cancelled by an earlier, interrupted cascade included - is discarded with {:discarded, run}, which is what makes re-running a cascade over an already-cancelled subtree a no-op.

opts accepts serialization: only, exactly as fail/4 does.

cascade_cancel(store, metadata_match, opts \\ [])

@spec cascade_cancel(
  store :: StatifierPersistence.Storage.t(),
  metadata_match :: StatifierPersistence.Storage.Adapter.metadata(),
  opts :: keyword()
) :: {:ok, non_neg_integer()} | {:error, error()}

Cancels every run linked to parent_run_id - for one invocation, or for all of them - and every run linked to those, recursively (ADR-0008 decision 5).

Retains: nothing is deleted and every position is left byte-identical; each run simply takes the :cancelled terminal status through cancel/3.

Idempotent, and idempotent in the strong sense a crash needs. The walk descends into every child it finds, whatever that child's own status, and cancel/3 discards a run that is already terminal - so a cascade interrupted halfway through a deep tree is completed correctly by re-running it, and a cascade over a subtree that is already fully cancelled writes nothing at all.

There is no global transaction and there deliberately is none: each run's cancel is its own serialized write under its own run's exclusion (ADR-0004 decision 5), so a deep tree is O(subtree) writes. Cross-run locking is the only way to make it atomic, and this package does not have it and does not want it.

Termination rests on the run tree being acyclic, which it is by construction: a child's run id strictly extends its parent's (StatifierPersistence.Run.Linkage.child_run_id/3), so no run can be its own descendant. This is why no depth ceiling is needed (ADR-0008 decision 6).

metadata_match is a StatifierPersistence.Run.Linkage containment map - Linkage.invocation_match/2 to cancel one invocation's subtree, Linkage.parent_match/1 for every child a parent has ever started. opts accepts serialization: only, threaded to every cancel/3 call the walk makes, exactly as cancel/3 itself accepts it.

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.