StatifierPersistence.Storage (StatifierPersistence v0.12.0)

Copy Markdown View Source

The guarded entry point from a storage adapter to a Statifier.MachineState.t().

Every load runs through load_position/3 or load_execution_position/3, and every load is checked against the exact chart revision that produced the stored position (ADR-0003 decision 2). No adapter callback ever holds both the stored identity and a caller-supplied Statifier.Machine.t() at the same time (StatifierPersistence.Storage.Adapter's moduledoc), so the guard cannot be skipped or weakened per adapter - it lives here, above every one of them, and nowhere else in this package decodes a position blob.

Every writer taking a machine or machine state - save_chart/3, save_position/3, insert_execution/5, update_execution/5 - derives a chart's content_hash and identity_blob from Machine.identity/1 on the machine it is given - never from a caller-supplied value - and refuses an unidentified machine with {:error, :unidentified_chart} rather than writing a row a later load has no way to check.

A chart is keyed by that content_hash and by nothing else. No tenant, namespace, or host scope takes part in the key, so two tenants that store byte-identical charts share one chart row: the hash is a content address, saving the same one twice is :ok, and it does not duplicate the row (StatifierPersistence.Storage.Adapter's save_chart/2 contract). The hash answers which chart these bytes are, never who stored them.

A multi-tenant host therefore tenant-qualifies its own per-chart rows, in its own tables, rather than expecting this package to do it. The package stores nothing per tenant; an execution's opaque metadata map (ADR-0006) is where a host tags an execution with the scope it already keys its own tables by, and any narrower scoping stays the host's.

Folding a namespace into the hash would change what a chart's identity is, and that is Statifier.Machine.identity/1's question - statifier-ex's contract, not this package's. Nothing here proposes it, and this package offers no option to do it.

Every function here returns an error tuple instead of throwing; nothing in this module ever downgrades a failure to a default value.

This module is also the storage seam's telemetry site (ADR-0009 decision 3): every adapter callback below is timed and reported as [:statifier_persistence, :adapter, :call], and every identity refusal

  • the guard's own and every writer's - as [:statifier_persistence, :identity, :refused]. Both are built by StatifierPersistence.Telemetry; see docs/telemetry.md for the contract.

Summary

Types

This facade's error vocabulary: an adapter's own arms plus Statifier's own unflattened blob-decode and identity arms (ADR-0003 decision 4). Every arm is returned, none is collapsed into a default.

Options the execution writers (insert_execution/5, update_execution/5) accept

One decoded input log entry (ADR-0010 decision 2), as list_inputs/2 returns it: the adapter's stored record with its opaque input_blob decoded back into the %Statifier.Event{} the interpreter saw.

t()

Functions

Appends event to execution_id's input log at door, returning the ordinal the adapter assigned (ADR-0010 decisions 2 and 3).

Validates a writer's metadata: option against store's adapter without writing anything: :ok, or {:error, :metadata_unsupported} for a non-empty map an adapter cannot store (ADR-0006 decision 3).

Whether store's adapter can list executions by a metadata match (ADR-0008 decision 5).

Whether store's adapter can store an execution's own outcome_blob (sp-t57, ruling C3).

Whether store's adapter can answer the indexed status projection (sp-t57, ruling C5).

Fetches the chart stored under content_hash.

Fetches the execution record stored under execution_id.

Whether store's adapter keeps an execution's input log (ADR-0010 decision 1).

Inserts an execution record for execution_id, keyed by the content hash and identity envelope of machine_state.machine's own Machine.identity/1 - never a caller-supplied hash - and refusing an unidentified machine with {:error, :unidentified_chart} before calling the adapter.

The indexed status projection over the same match list_executions_by_metadata/2 takes (sp-t57, ruling C5).

Lists the executions whose stored metadata contains every key/value pair in metadata (ADR-0008 decision 5).

Lists execution_id's whole input log in ascending seq, decoded (ADR-0010 decision 2).

Fetches the execution stored under execution_id and rebuilds its position into a Statifier.MachineState.t() walking machine, refusing a chart-revision mismatch instead of silently resuming the wrong configuration.

Fetches the position stored for session_id and rebuilds it into a Statifier.MachineState.t() walking machine, refusing a chart-revision mismatch instead of silently resuming the wrong configuration.

Whether store's adapter can store an execution's opaque metadata map (ADR-0006 decision 3).

Initializes adapter with opts and returns the handle every other function in this module takes as its first argument.

Stores chart_blob under the content hash and identity envelope of machine's own Machine.identity/1 - never a caller-supplied hash.

Encodes machine_state with Position.to_binary/1 and stores it under session_id, keyed by the content hash and identity envelope of machine_state.machine's own Machine.identity/1 - never a caller-supplied hash, so a caller cannot store a position under a hash that disagrees with its blob.

Overwrites the execution stored under execution_id with a full record derived the same way insert_execution/5 derives one: identity always from machine_state.machine's own Machine.identity/1, refusal of an unidentified machine, position_blob encoded under position: :persist (the default).

Overwrites only the status and failure of the execution stored under execution_id, carrying every other stored field - both blobs included - forward verbatim.

Types

error()

@type error() ::
  StatifierPersistence.Storage.Adapter.error()
  | :not_a_statifier_blob
  | :unidentified_chart
  | :execution_position_missing
  | :metadata_unsupported
  | :child_listing_unsupported
  | :execution_outcome_unsupported
  | :execution_states_unsupported
  | {:unsupported_format_version, term()}
  | {:identity_mismatch, Statifier.Machine.Identity.t(),
     Statifier.Machine.Identity.t() | nil}

This facade's error vocabulary: an adapter's own arms plus Statifier's own unflattened blob-decode and identity arms (ADR-0003 decision 4). Every arm is returned, none is collapsed into a default.

execution_write_opt()

@type execution_write_opt() ::
  {:failure, String.t() | nil}
  | {:position, :persist | :skip}
  | {:metadata, StatifierPersistence.Storage.Adapter.metadata()}
  | {:outcome_blob, binary() | nil}

Options the execution writers (insert_execution/5, update_execution/5) accept:

  • failure: - the short reason stored on a :failed execution; defaults to nil.
  • position: - :persist (the default) encodes the given machine state with Position.to_binary/1 and stores it as the execution's position_blob; :skip stores nil on insert and carries the currently stored blob forward verbatim on update.
  • metadata: - insert_execution/5 only: the optional opaque map of host identities stored beside the execution (ADR-0006 decision 1), defaulting to %{}. It is write-once - update_execution/5 and update_execution_status/4 carry the stored map forward and accept no metadata: of their own.
  • outcome_blob: - update_execution_status/4 only: the execution's own opaque answer, written once when it reaches a terminal status. Defaults to nil, which carries the stored value forward rather than clearing it, so every other writer leaves an answer already recorded alone.

input()

One decoded input log entry (ADR-0010 decision 2), as list_inputs/2 returns it: the adapter's stored record with its opaque input_blob decoded back into the %Statifier.Event{} the interpreter saw.

A nil event is the closed marker the cap wrote (decision 6) and nothing else. door is one of StatifierPersistence.Executions.entry/0's seven atoms, as its string - the key decision 8's replay mapping is written against.

t()

@type t() :: %StatifierPersistence.Storage{
  adapter: module(),
  opts: StatifierPersistence.Storage.Adapter.opts()
}

Functions

append_input(store, execution_id, door, event)

@spec append_input(
  store :: t(),
  execution_id :: StatifierPersistence.Storage.Adapter.execution_id(),
  door :: atom(),
  event :: Statifier.Event.t()
) ::
  {:ok, StatifierPersistence.Storage.Adapter.seq()}
  | :not_supported
  | {:error, error()}

Appends event to execution_id's input log at door, returning the ordinal the adapter assigned (ADR-0010 decisions 2 and 3).

This is the encode site: the %Statifier.Event{} the interpreter was handed crosses the adapter seam as an opaque input_blob, encoded here with :erlang.term_to_binary/1 in exactly outcome_blob's established shape. ADR-0003 decision 1 therefore stays true word for word - an adapter sees binaries, strings and an ordinal, never a Statifier struct - and this package mints no serialization format of its own.

:not_supported for an adapter that keeps no log, without calling the adapter at all. {:error, :input_log_full} once the execution's log has closed itself at the host's cap (decision 6): that arm refuses the append and never the step, and the caller carries on.

door is a StatifierPersistence.Executions.entry/0 atom - the fixed vocabulary of public doors, stored as its string.

check_metadata(store, opts)

@spec check_metadata(store :: t(), opts :: [execution_write_opt()]) ::
  :ok | {:error, error()}

Validates a writer's metadata: option against store's adapter without writing anything: :ok, or {:error, :metadata_unsupported} for a non-empty map an adapter cannot store (ADR-0006 decision 3).

insert_execution/5 runs this check itself, so a caller writing through the facade alone never needs it. It is public for the caller that has work to do before the write and must not do it for a create that will be refused: StatifierPersistence.Executions.create/4 runs it ahead of Statifier.Interpreter.initialize/2 so no effect is executed for an execution whose metadata cannot be stored - the same reason insert_execution/5's identity refusal runs before the position encode. Raises ArgumentError on a malformed option, exactly as the writers do.

child_listing_supported?(store)

@spec child_listing_supported?(store :: t()) :: boolean()

Whether store's adapter can list executions by a metadata match (ADR-0008 decision 5).

True when the adapter exports the optional StatifierPersistence.Storage.Adapter.list_executions_by_metadata/2 and metadata_supported?/1 holds for this store. This is the predicate list_executions_by_metadata/2 consults for its own refusal-at-open, exposed because a caller with work to do before the query - such as StatifierPersistence.Driver refusing to start a child it could never enumerate for cancellation - wants the answer before it acts.

The metadata conjunct is not belt and braces. A metadata match is a query over stored metadata, so an adapter that declares it cannot support metadata (ADR-0006 decision 3) cannot answer one either, and an adapter whose declaration is conditional - the shipped Ecto adapter says false off Postgres, where the containment SQL does not parse - is otherwise reported as able to list purely because the function is compiled into it. Exported and able are different questions and this is the one callers ask (sp-11w).

execution_outcome_supported?(store)

@spec execution_outcome_supported?(store :: t()) :: boolean()

Whether store's adapter can store an execution's own outcome_blob (sp-t57, ruling C3).

True when the adapter exports the optional StatifierPersistence.Storage.Adapter.supports_execution_outcome?/1 and it answers true - the same shape metadata_supported?/1 checks. This is half of StatifierPersistence.Driver.start_child_at/6's refusal at open: a fan-out child whose answer could never be read back could never settle its invocation.

execution_states_supported?(store)

@spec execution_states_supported?(store :: t()) :: boolean()

Whether store's adapter can answer the indexed status projection (sp-t57, ruling C5).

True when the adapter exports the optional StatifierPersistence.Storage.Adapter.list_execution_states_by_metadata/2 and metadata_supported?/1 holds - the same pair child_listing_supported?/1 checks, and for the same reason: the projection is the same metadata match, narrowed to three columns. The other half of start_child_at/6's refusal at open.

fetch_chart(store, content_hash)

@spec fetch_chart(
  store :: t(),
  content_hash :: StatifierPersistence.Storage.Adapter.content_hash()
) ::
  {:ok, StatifierPersistence.Storage.Adapter.chart_record()} | {:error, error()}

Fetches the chart stored under content_hash.

fetch_execution(store, execution_id)

@spec fetch_execution(
  store :: t(),
  execution_id :: StatifierPersistence.Storage.Adapter.execution_id()
) ::
  {:ok, StatifierPersistence.Storage.Adapter.execution_record()}
  | {:error, error()}

Fetches the execution record stored under execution_id.

input_log_supported?(store)

@spec input_log_supported?(store :: t()) :: boolean()

Whether store's adapter keeps an execution's input log (ADR-0010 decision 1).

True when the adapter exports the optional StatifierPersistence.Storage.Adapter.supports_input_log?/1 and it answers true - the same shape metadata_supported?/1 checks. Nothing refuses on it: an adapter that keeps no log runs every chart exactly as it did before ADR-0010, and this predicate is public so a host that needs a replayable execution can find out whether it will get one before it drives one.

insert_execution(store, execution_id, machine_state, status, opts \\ [])

@spec insert_execution(
  store :: t(),
  execution_id :: StatifierPersistence.Storage.Adapter.execution_id(),
  machine_state :: Statifier.MachineState.t(),
  status :: StatifierPersistence.Storage.Adapter.execution_status(),
  opts :: [execution_write_opt()]
) :: :ok | {:error, error()}

Inserts an execution record for execution_id, keyed by the content hash and identity envelope of machine_state.machine's own Machine.identity/1 - never a caller-supplied hash - and refusing an unidentified machine with {:error, :unidentified_chart} before calling the adapter.

Writers always take a MachineState: even an execution failed at creation has one, because Statifier.Interpreter.initialize/2 cannot fail (ADR-0004 decision 1). Under position: :persist (the default) the state is encoded with Position.to_binary/1 and stored as the execution's position_blob; under position: :skip the blob is stored nil - the arm for an execution with no quiescent position to store. Uniqueness comes from the adapter's insert_execution/2 :execution_exists refusal, not a pre-check here.

metadata: is the optional opaque map of host identities ADR-0006 decision 1 grants, defaulting to %{}. Create is the only place it is set. This function is where the refusal-at-open lives: a non-empty map for an adapter that does not export StatifierPersistence.Storage.Adapter.supports_metadata?/1 (or whose answer is false) returns {:error, :metadata_unsupported} before any write, and an empty or absent map is never refused. A map that is not a map of string keys raises ArgumentError: the shape is the one thing ADR-0006 decision 1 does validate, and a malformed option is a caller bug, not a storage event.

The map carries host identities only, never personal data (ADR-0006 decision 2). :blob_type encryption reaches the three blob columns and not this map, so a name, an email address, or a card number filed here is at rest in the clear. Nothing in this package can enforce that - the map is opaque - so the contract states it and the host keeps it.

list_execution_states_by_metadata(store, metadata)

@spec list_execution_states_by_metadata(
  store :: t(),
  metadata :: StatifierPersistence.Storage.Adapter.metadata()
) ::
  {:ok, [StatifierPersistence.Storage.Adapter.execution_state()]}
  | {:error, error()}

The indexed status projection over the same match list_executions_by_metadata/2 takes (sp-t57, ruling C5).

Delegates to the adapter's optional StatifierPersistence.Storage.Adapter.list_execution_states_by_metadata/2 when execution_states_supported?/1 is true; returns {:error, :execution_states_unsupported} otherwise, without calling the adapter at all.

This is the read a fan-out's settlement uses to ask whether every child of an invocation has reached a terminal status. It exists so that question does not cost N whole records - blobs included - once per child.

list_executions_by_metadata(store, metadata)

@spec list_executions_by_metadata(
  store :: t(),
  metadata :: StatifierPersistence.Storage.Adapter.metadata()
) ::
  {:ok, [StatifierPersistence.Storage.Adapter.execution_record()]}
  | {:error, error()}

Lists the executions whose stored metadata contains every key/value pair in metadata (ADR-0008 decision 5).

Delegates to the adapter's optional StatifierPersistence.Storage.Adapter.list_executions_by_metadata/2 when child_listing_supported?/1 is true; returns {:error, :child_listing_unsupported} for an adapter that does not export it, without calling the adapter at all.

list_inputs(store, execution_id)

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

Lists execution_id's whole input log in ascending seq, decoded (ADR-0010 decision 2).

The decode half of append_input/4: each stored input_blob comes back as the %Statifier.Event{} that was delivered, equal to the one the interpreter saw, caller_context and all. An entry whose event is nil is the closed marker the cap wrote (decision 6) and nothing else - a reader that maps this log onto a replay refuses on it rather than replaying an execution that never happened.

:not_supported for an adapter that keeps no log; {:error, :execution_not_found} for an execution that does not exist; {:ok, []} for an execution with no inputs.

load_execution_position(store, execution_id, machine)

@spec load_execution_position(
  store :: t(),
  execution_id :: StatifierPersistence.Storage.Adapter.execution_id(),
  machine :: Statifier.Machine.t()
) :: {:ok, Statifier.MachineState.t()} | {:error, error()}

Fetches the execution stored under execution_id and rebuilds its position into a Statifier.MachineState.t() walking machine, refusing a chart-revision mismatch instead of silently resuming the wrong configuration.

Executions in load_position/3's order, with one extra arm: the same cheap identity pre-check against the stored identity_blob, then {:error, :execution_position_missing} for an execution whose position_blob is nil (an execution that failed at creation stores none - ADR-0004 decision 1), then Position.from_binary/2 as the authoritative check, its result returned unchanged.

Like load_position/3, the returned state carries nil for both routes and invoke_types (st-ADR-0064); re-stamping them before the next drive is the stepper's job, not this function's.

load_position(store, session_id, machine)

@spec load_position(
  store :: t(),
  session_id :: StatifierPersistence.Storage.Adapter.session_id(),
  machine :: Statifier.Machine.t()
) :: {:ok, Statifier.MachineState.t()} | {:error, error()}

Fetches the position stored for session_id and rebuilds it into a Statifier.MachineState.t() walking machine, refusing a chart-revision mismatch instead of silently resuming the wrong configuration.

Executions in this order, and the order is the contract:

  1. fetch_position/2 on the adapter. :position_not_found and {:adapter, term()} pass straight through.
  2. The cheap pre-check: decodes the stored identity_blob and compares it against Machine.identity(machine) with Identity.matches?/2 - never ==/2 (st-ADR-0052 decision 1). A mismatch returns {:error, {:identity_mismatch, stored, supplied}} without paying the position decode. machine carrying no identity returns {:error, :unidentified_chart}. A stored identity_blob that does not decode returns {:error, :not_a_statifier_blob} (or {:error, {:unsupported_format_version, version}}).
  3. Position.from_binary/2 - the authoritative check. Its result is returned unchanged, all four error arms included.

Step 2 is an optimization that must never disagree with step 3: both reuse Identity.matches?/2 and produce the same {:identity_mismatch, expected, actual} arm, whichever check fires.

The returned MachineState.t() carries nil for both routes and invoke_types. Neither survives the round trip: Position.to_binary/1 drops both alongside :machine when it encodes the payload, and from_binary/2 drops both from the decoded payload before it rebuilds the struct - unconditionally, so a blob written by an older encoder decodes to nil too (st-ADR-0064, which amends st-ADR-0052 in part).

A caller must therefore stamp both before the next drive, via MachineState.put_routes/2 and MachineState.put_invoke_types/2: both are per-drive/per-session snapshots (st-ADR-0048, st-ADR-0051), and a persisted position is not the place they live. Stamping is the stepper's job (sp-4an.2), not this function's; this function is documented here as the place a reader learns the snapshot does not come back.

metadata_supported?(store)

@spec metadata_supported?(store :: t()) :: boolean()

Whether store's adapter can store an execution's opaque metadata map (ADR-0006 decision 3).

True when the adapter exports the optional StatifierPersistence.Storage.Adapter.supports_metadata?/1 and it answers true for this handle. This is the predicate insert_execution/5's refusal-at-open consults, exposed because a host choosing between an adapter's scope query and its own side table wants the answer before it writes, and because the conformance suite branches on it.

new(adapter, opts)

@spec new(adapter :: module(), opts :: StatifierPersistence.Storage.Adapter.opts()) ::
  {:ok, t()} | {:error, error()}

Initializes adapter with opts and returns the handle every other function in this module takes as its first argument.

save_chart(store, machine, chart_blob)

@spec save_chart(
  store :: t(),
  machine :: Statifier.Machine.t(),
  chart_blob :: binary()
) ::
  :ok | {:error, error()}

Stores chart_blob under the content hash and identity envelope of machine's own Machine.identity/1 - never a caller-supplied hash.

chart_blob is stored verbatim and is the one thing this function does not derive or inspect (ADR-0003 decision 1). Returns {:error, :unidentified_chart} when machine carries no identity, without calling the adapter at all.

save_position(store, session_id, machine_state)

@spec save_position(
  store :: t(),
  session_id :: StatifierPersistence.Storage.Adapter.session_id(),
  machine_state :: Statifier.MachineState.t()
) :: :ok | {:error, error()}

Encodes machine_state with Position.to_binary/1 and stores it under session_id, keyed by the content hash and identity envelope of machine_state.machine's own Machine.identity/1 - never a caller-supplied hash, so a caller cannot store a position under a hash that disagrees with its blob.

{:error, :unidentified_chart} from Position.to_binary/1 is returned unchanged: st-ADR-0052 decision 4's structural guarantee that no unverifiable blob can be written arrives intact at this layer too.

update_execution(store, execution_id, machine_state, status, opts \\ [])

@spec update_execution(
  store :: t(),
  execution_id :: StatifierPersistence.Storage.Adapter.execution_id(),
  machine_state :: Statifier.MachineState.t(),
  status :: StatifierPersistence.Storage.Adapter.execution_status(),
  opts :: [execution_write_opt()]
) :: :ok | {:error, error()}

Overwrites the execution stored under execution_id with a full record derived the same way insert_execution/5 derives one: identity always from machine_state.machine's own Machine.identity/1, refusal of an unidentified machine, position_blob encoded under position: :persist (the default).

Under position: :skip the currently stored position_blob is carried forward verbatim: because the adapter's update_execution/2 is a full-record overwrite, this function fetches the current record and reuses its blob bytes unchanged, so a status-only update (a failed step, an abandonment) never touches the stored position. Returns {:error, :execution_not_found} when no execution exists for the id.

An execution's metadata is write-once (ADR-0006 decision 1 grants a map at create and no way to change it), so this function accepts no metadata: option and passes %{} in the record; the adapter carries the stored map forward verbatim, as StatifierPersistence.Storage.Adapter.update_execution/2 records.

update_execution_status(store, execution_id, status, opts \\ [])

@spec update_execution_status(
  store :: t(),
  execution_id :: StatifierPersistence.Storage.Adapter.execution_id(),
  status :: StatifierPersistence.Storage.Adapter.execution_status(),
  opts :: [execution_write_opt()]
) :: :ok | {:error, error()}

Overwrites only the status and failure of the execution stored under execution_id, carrying every other stored field - both blobs included - forward verbatim.

This is the writer for a host-driven terminal transition that has no MachineState in hand (StatifierPersistence.Executions.fail/4, ADR-0004 decision 6): nothing is derived, decoded, or re-encoded, so the identity guard is preserved by construction - the stored identity_blob and position_blob bytes never change. opts accepts failure: and outcome_blob: (both default nil). Returns {:error, :execution_not_found} when no execution exists for the id.

outcome_blob: is the one writer of an execution's own answer, and this is the right writer for it: an answer is recorded exactly when an execution reaches a terminal status, which is the transition this function exists for, and nothing else about the record is touched. A nil (the default) carries the stored blob forward, so a status-only update never erases an answer.