StatifierPersistence.Executions (StatifierPersistence v0.12.0)

Copy Markdown View Source

The execution lifecycle: create and step durable executions 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 execution 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 execution 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 execution is discarded with a typed {:discarded, execution} result, never an exception and never a silent step.

A chart says its own execution failed

Two routes reach :failed, and both are the chart's own word rather than the host's - fail/4 is the host-driven one (ADR-0004 decision 6).

The first is macrostep-budget exhaustion, which also returns {:error, {:budget_exhausted, payload}} after the record is durable.

The second (ADR-0008's 2026-09-06 amendment) is a failure-classed final: a top-level <final> whose <donedata> carries the reserved key statifier_persistence:execution_status with the value "failed".

<final id="ended_badly">
  <donedata>
    <param name="statifier_persistence:execution_status" expr="'failed'"/>
  </donedata>
</final>

Settling there is an ordinary successful step - it returns {:ok, %StatifierPersistence.Execution{status: :failed}, machine_state}, not the budget route's error tuple, because a chart that says it failed has not malfunctioned, it has finished. The execution's failure string is "failed_final", the same string the [:statifier_persistence, :execution, :terminated] event reports as reason and StatifierPersistence.Driver sends a durable parent as {:failed, reason: ...}, so a :first_error fan-out cancels the failed child's siblings through the cascade ADR-0008 decision 5 already built. The resolved <donedata> reaches the parent verbatim, tag included - nothing is stripped.

The value set is closed at "failed": any other value is ignored and the execution takes the status it would have taken with no key at all, so a chart cannot claim a :completed it did not reach or a :cancelled that is the parent's word. An unhandled error.communication or error.execution is not a route: a chart that raises an error it does not catch stays :active, which is a chart bug its author fixes with a transition to a failure-classed final, not a status this package infers on the author's behalf (amendment decision 4).

Before 0.12.0 the reserved key was spelled statifier_persistence:run_status. That spelling is still read in 0.12.0 and is dropped in 0.13.0 (ADR-0011 decision 4): where both keys are present the new one wins, and reading the old one logs one deprecation line at :debug naming the new key.

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 execution are ordered by a pluggable per-execution serialization strategy (ADR-0004 decision 5): every entry point runs its fetch-to-persist tail inside the strategy's StatifierPersistence.Serialization.with_execution/3, selected per call with serialization: {module, config} and defaulting to {StatifierPersistence.Serialization.AdapterLock, store} - the adapter's own optional lock_execution/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 execution record, plus the serialization strategy's own refusal, surfaced unchanged ({:serialization, :not_supported} from the default strategy over an adapter with no lock_execution/3).

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

An execution's caller-supplied opaque key (ADR-0004 decision 2).

Options create/4 and step/5 accept

Functions

Cancels an execution: 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 execution linked to parent_execution_id - for one invocation, or for all of them - and every execution linked to those, recursively (ADR-0008 decision 5).

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

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

Lists execution_id's input log, in the order the execution's interpreter saw it (ADR-0010 decision 2).

Delivers one external event to an execution, 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.

It is also ADR-0010's door vocabulary: the same seven atoms, stored as strings on an input log entry, and the record adds no second one. Of the seven, only :step, :done_invocation and :failed_invocation - :answer_parent among them, since it re-enters the parent through one of the two invocation doors - ever carry an event into an interpreter, so those are the doors that append (decision 5's table).

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 execution record, plus the serialization strategy's own refusal, surfaced unchanged ({:serialization, :not_supported} from the default strategy over an adapter with no lock_execution/3).

event_builder()

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

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

Called with the loaded, re-stamped Statifier.MachineState.t/0, inside the serialization strategy's with_execution/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, execution}.

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.

execution_id()

An execution's caller-supplied opaque key (ADR-0004 decision 2).

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.Execution.Linkage.t()}
  | {:entry, entry()}
  | {:invoke_id, String.t()}
  | {:child_count, pos_integer()}
  | {:step_reporter, ([Statifier.Effect.t()] -> any())}

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 execution 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-execution 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: the public door this drive came through, carried on [:statifier_persistence, :execution, :step, :start | :stop] and [:statifier_persistence, :execution, :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). It stopped being telemetry-only with ADR-0010: on an adapter that keeps an input log, entry: also stamps the stored entry's door (decision 5). It changes nothing else.
  • 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.Execution.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.
  • invoke_id: and child_count: - this package's own, never a host's, and telemetry only. Set by StatifierPersistence.Driver beside entry: :answer_parent, they name the invocation the step is answering and its width on [:statifier_persistence, :execution, :step, :stop] (the ADR-0009 sp-8wv amendment). child_count is nil for a single-child subchart. They change nothing else about the step.
  • step_reporter: - this package's own, never a host's, and the seam ADR-0008's after_step: amendment (2026-09-08) needed: a 1-arity fun this call hands the step's WHOLE effect list to - the list this module's persist tail is handed, before it splits the lifecycle effects off - once the persist has landed and before the entry point returns. Set by StatifierPersistence.Driver when, and only when, its own after_step: is set, and set to a fun that records the list rather than acting on it: the driver fires the host's callback itself, after this function has returned and outside the execution's own exclusion (the amendment's clause 3). It is a reporter and not the callback because the amendment rules out widening this module's public returns to carry the list, and because nothing a host wrote should run inside a serialized section this package opened. Its return value is discarded and it changes nothing about the step.

Functions

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

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

Cancels an execution: 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. An execution that is already terminal - cancelled by an earlier, interrupted cascade included - is discarded with {:discarded, execution}, which is what makes re-running a cascade over an already-cancelled subtree a no-op.

opts accepts serialization: only - fail/4's serialization:, without its driver:: no chart is stepped by a cancel, on either side of a linkage.

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 execution linked to parent_execution_id - for one invocation, or for all of them - and every execution linked to those, recursively (ADR-0008 decision 5).

Retains: nothing is deleted and every position is left byte-identical; each execution 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 an execution 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 execution's cancel is its own serialized write under its own execution's exclusion (ADR-0004 decision 5), so a deep tree is O(subtree) writes. Cross-execution 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 execution tree being acyclic, which it is by construction: a child's execution id strictly extends its parent's (StatifierPersistence.Execution.Linkage.child_execution_id/3), so no execution can be its own descendant. This is why no depth ceiling is needed (ADR-0008 decision 6).

That same fact is what makes the lock order safe, which is worth stating because this walk is the one place a cycle would be conceivable. It runs from inside the caller's own exclusion on every path that has one - the {:cancel_invoke, _} effect fires inside the exiting execution's, and first_error's settlement fires it inside the PARENT's - and it only ever takes an exclusion on an execution further down that same subtree. Nothing here holds a descendant's exclusion and then asks for an ancestor's: a child releases its own before answering its parent (Driver.maybe_answer_parent/3 runs after the drive returns), and the parent's door is stepped after the settlement's exclusion closes rather than inside it. So the wait-for relation between two connections embeds in the execution tree, and an acyclic tree has no cycle to deadlock on. test/statifier_persistence/driver_fanout_test.exs pins the direction; its Ecto variant runs it against real Postgres advisory locks.

metadata_match is a StatifierPersistence.Execution.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, execution_id, machine, opts)

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

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

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

A create whose initialize/2 exhausts its macrostep budget persists a :failed execution 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 execution 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, execution_id, reason, opts \\ [])

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

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

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

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

driver: and a linked child (ADR-0008's outside-fail note)

An execution failed here is failed from outside the interpreter, so nothing in this call steps a chart and nothing in it reaches the parent of a durable subchart child (ADR-0008 decision 2's linkage). Left there, a child a host abandons this way holds its parent's <invoke> :pending forever: every path that answers a parent hangs off a drive of the child, and an outside fail is the one terminal transition that has no drive.

driver: is that answer. Given a StatifierPersistence.Driver.t/0, an execution that carried linkage and actually reached :failed here answers its parent with {:failed, reason: reason} - the ADR-0008 spelling, the same payload the automatic path builds from an execution's stored failure - through StatifierPersistence.Driver.resolve_and_answer_parent/3, the same write site the stepped path uses. A fan-out child settles rather than answering, because that routing lives in StatifierPersistence.Driver.answer_parent/3 and both paths reach it.

The driver must be able to answer the parent: either its chart_resolver: resolves the parent's chart, or its machine already is the parent's chart. Its store is what the answer reads and writes through, so it is normally a driver over this same store.

Two boundaries this option does not cross. The answer happens after this execution's own serialization section commits, not inside it - the same order create/3 and send_event/4 answer in, and the reason a nested exclusion is never taken here. And the answer's own outcome does not change this function's: a parent that has already cancelled the invocation, or that cannot be resolved, leaves {:ok, execution} exactly as it is. What that window costs, and what closes it, is docs/adr/0008-durable-subchart-child-runs.md's note.

Without driver: nothing about this call changes, for a linked execution or an unlinked one: no linkage is read and no parent is answered.

inputs(store, execution_id)

@spec inputs(
  store :: StatifierPersistence.Storage.t(),
  execution_id :: execution_id()
) ::
  {:ok, [StatifierPersistence.Storage.input()]}
  | :not_supported
  | {:error, error()}

Lists execution_id's input log, in the order the execution's interpreter saw it (ADR-0010 decision 2).

Each entry carries its ordinal (seq, dense from zero), the public door it entered by, and the %Statifier.Event{} itself - equal to the one that was delivered, caller_context and all. An entry whose event is nil is the closed marker a host-declared cap wrote (decision 6); a reader mapping this log onto a replay refuses on it rather than replaying an execution that never happened.

:not_supported for a store whose adapter keeps no log - which is not a failure, since nothing in this package refuses an execution over it (decision 1). {:error, :execution_not_found} for an execution that does not exist, and {:ok, []} for one that has taken no input yet.

Read-only and outside the execution's exclusion by design: this is a diagnostic read, and nothing in this package consumes it. The replay itself is StatifierUI.Trace.Replay.from_events/4's, under the mapping ADR-0010 decision 8 names and no code here builds.

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

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

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

An event delivered to a terminal execution returns {:discarded, execution} from the execution record alone, before any position decode. handle_event/2's {:error, :not_running} arm is the structural backstop for an execution 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, execution} arm.