The reference StatifierPersistence.Storage.Adapter: an Agent holding
three maps - charts keyed by content hash, positions keyed by session id,
and executions keyed by execution id.
The chart map is keyed by the content hash alone, as every adapter's is:
byte-identical charts stored by two tenants are one entry, and tenant
scoping is the host's own (StatifierPersistence.Storage's moduledoc).
It ships in lib/, not the test-only support/ directory, for two
reasons: the conformance template this package ships in lib/ (this
package's own Testing namespace) needs a reference implementation to
check against from outside this repository's own test/, and a host
prototyping the stepper wants an adapter with no database to stand up.
Summary
Types
This adapter's state: the three record maps init/1 starts the Agent
with, plus the per-execution lock table lock_execution/3 acquires through.
Functions
Counts the executions on content_hash per stored arm (the optional
StatifierPersistence.Storage.Adapter.count_executions_by_content_hash/2,
ADR-0012 decision 3).
Fetches the chart stored under content_hash, or :chart_not_found.
Fetches the execution stored under execution_id, or :execution_not_found.
Fetches the position stored for session_id, or :position_not_found.
Reads the tombstone on content_hash, or nil for a hash that has
none (the optional
StatifierPersistence.Storage.Adapter.fetch_retired_info/2).
Starts the backing Agent and returns opts with :pid merged in - the
handle every other callback expects as its first argument.
Inserts execution_record under its execution_id, refusing a duplicate with
{:error, :execution_exists}.
Lists the ids of the :active executions on content_hash (the
optional
StatifierPersistence.Storage.Adapter.list_active_execution_ids_by_content_hash/2,
ADR-0012 decision 4): what a pin source is handed as its context.
The status projection over a metadata match (the optional
StatifierPersistence.Storage.Adapter.list_execution_states_by_metadata/2).
Lists the executions whose stored metadata contains every key/value pair
in metadata, recursively for a nested map (the optional
StatifierPersistence.Storage.Adapter.list_executions_by_metadata/2,
ADR-0008 decision 5) - the same subset semantics
StatifierPersistence.Storage.Ecto's jsonb @> gives, and the same
ArgumentError on an empty or non-string-keyed map.
Runs fun under this adapter's per-execution mutual exclusion for execution_id
(the optional StatifierPersistence.Storage.Adapter.lock_execution/3).
Retires the chart on content_hash (the optional
StatifierPersistence.Storage.Adapter.retire_chart/3, ADR-0012
decisions 5 and 6).
Stores chart_record under its content_hash, idempotent on repeat
writes of the same hash.
Stores position_record under its session_id, overwriting any position
already stored for that session.
Declares chart retirement (the optional
StatifierPersistence.Storage.Adapter.supports_chart_retirement?/1,
ADR-0012 decision 6): an Agent's map has no NOT NULL to drop, so
the backend limit V07 records on a database adapter has no
counterpart here.
Declares the drained query (the optional
StatifierPersistence.Storage.Adapter.supports_content_hash_query?/1):
this adapter holds every execution record in one map and can count them.
Declares outcome support (the optional
StatifierPersistence.Storage.Adapter.supports_execution_outcome?/1): this
adapter keeps the blob on the execution record like every other field.
Declares metadata support (the optional
StatifierPersistence.Storage.Adapter.supports_metadata?/1): this
adapter stores the map with the execution record and returns it verbatim
(ADR-0006 decision 3).
Declares the narrow tombstone read (the optional
StatifierPersistence.Storage.Adapter.supports_retired_info?/1).
Declares the tree migration unit (the optional
StatifierPersistence.Storage.Adapter.supports_tree_migration?/1,
ADR-0015 decision 3).
Overwrites the execution stored under execution_record's execution_id with the full
record, or refuses with :execution_not_found when no execution exists for the id.
Writes a tree migration's re-pins and parks as one unit (the optional
StatifierPersistence.Storage.Adapter.write_tree_migration/2,
ADR-0015 decision 3).
Types
@type state() :: %{ charts: %{ required(StatifierPersistence.Storage.Adapter.content_hash()) => StatifierPersistence.Storage.Adapter.chart_record() }, positions: %{ required(StatifierPersistence.Storage.Adapter.session_id()) => StatifierPersistence.Storage.Adapter.position_record() }, executions: %{ required(StatifierPersistence.Storage.Adapter.execution_id()) => StatifierPersistence.Storage.Adapter.execution_record() }, locks: %{ required(StatifierPersistence.Storage.Adapter.execution_id()) => reference() } }
This adapter's state: the three record maps init/1 starts the Agent
with, plus the per-execution lock table lock_execution/3 acquires through.
Functions
@spec count_executions_by_content_hash( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.content_hash() ) :: {:ok, StatifierPersistence.Storage.Adapter.execution_counts()} | {:error, StatifierPersistence.Storage.Adapter.error()}
Counts the executions on content_hash per stored arm (the optional
StatifierPersistence.Storage.Adapter.count_executions_by_content_hash/2,
ADR-0012 decision 3).
Every key is present for every hash, so an unknown one answers zeros. A fold over the execution map is what this adapter has - an Agent holds no index - so this is the reference implementation of the contract, not of the aggregate the contract exists for; the Ecto adapter is where the count is a grouped count.
children counts the durable-child linkage pins naming content_hash
whose parent execution is :active or :needs_migration (ADR-0012
decision 1, as ADR-0014 decision 4 reads it): a second
pass over the same map, reading each execution's reserved metadata key
through StatifierPersistence.Execution.Linkage.from_metadata/1 and
looking its parent up by execution id.
@spec fetch_chart( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.content_hash() ) :: {:ok, StatifierPersistence.Storage.Adapter.chart_record()} | {:error, StatifierPersistence.Storage.Adapter.error()}
Fetches the chart stored under content_hash, or :chart_not_found.
A tombstoned hash answers {:error, {:chart_retired, info}} instead
(ADR-0012 decision 6): the entry is still there and its blobs are
nil, and an entry with nil blobs is not a chart this adapter
holds.
@spec fetch_execution( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.execution_id() ) :: {:ok, StatifierPersistence.Storage.Adapter.execution_record()} | {:error, StatifierPersistence.Storage.Adapter.error()}
Fetches the execution stored under execution_id, or :execution_not_found.
@spec fetch_position( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.session_id() ) :: {:ok, StatifierPersistence.Storage.Adapter.position_record()} | {:error, StatifierPersistence.Storage.Adapter.error()}
Fetches the position stored for session_id, or :position_not_found.
@spec fetch_retired_info( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.content_hash() ) :: {:ok, StatifierPersistence.Storage.Adapter.retired_info() | nil}
Reads the tombstone on content_hash, or nil for a hash that has
none (the optional
StatifierPersistence.Storage.Adapter.fetch_retired_info/2).
The Agent answers with the tombstone alone rather than the stored entry, so the reply carries no chart bytes.
@spec init(StatifierPersistence.Storage.Adapter.opts()) :: {:ok, StatifierPersistence.Storage.Adapter.opts()} | {:error, StatifierPersistence.Storage.Adapter.error()}
Starts the backing Agent and returns opts with :pid merged in - the
handle every other callback expects as its first argument.
@spec insert_execution( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.execution_record() ) :: :ok | {:error, StatifierPersistence.Storage.Adapter.error()}
Inserts execution_record under its execution_id, refusing a duplicate with
{:error, :execution_exists}.
The exists-check and the write run inside one Agent.get_and_update/2
call, so they are a single atomic state transition: two concurrent
inserts of the same execution_id cannot both return :ok.
This adapter supports the optional metadata map (ADR-0006 decision 3):
the map is stored with the record and returned by fetch_execution/2 verbatim,
whatever Elixir terms it holds - an Agent has no type system to refuse
one.
@spec list_active_execution_ids_by_content_hash( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.content_hash() ) :: {:ok, [StatifierPersistence.Storage.Adapter.execution_id()]} | {:error, StatifierPersistence.Storage.Adapter.error()}
Lists the ids of the :active executions on content_hash (the
optional
StatifierPersistence.Storage.Adapter.list_active_execution_ids_by_content_hash/2,
ADR-0012 decision 4): what a pin source is handed as its context.
A filter over the same execution map the counts fold over, in the order the map yields, which is no order a caller may rely on: the contract is a list of ids, not a sequence.
@spec list_execution_states_by_metadata( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.metadata() ) :: {:ok, [StatifierPersistence.Storage.Adapter.execution_state()]} | {:error, StatifierPersistence.Storage.Adapter.error()}
The status projection over a metadata match (the optional
StatifierPersistence.Storage.Adapter.list_execution_states_by_metadata/2).
The same containment list_executions_by_metadata/2 applies, projected down
to the three StatifierPersistence.Storage.Adapter.execution_state/0
fields. There is no index to serve it from here - an Agent holds a map -
so this is the reference implementation of the contract, not of the
performance the contract exists for; the Ecto adapter is where the
projection is a projection.
@spec list_executions_by_metadata( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.metadata() ) :: {:ok, [StatifierPersistence.Storage.Adapter.execution_record()]} | {:error, StatifierPersistence.Storage.Adapter.error()}
Lists the executions whose stored metadata contains every key/value pair
in metadata, recursively for a nested map (the optional
StatifierPersistence.Storage.Adapter.list_executions_by_metadata/2,
ADR-0008 decision 5) - the same subset semantics
StatifierPersistence.Storage.Ecto's jsonb @> gives, and the same
ArgumentError on an empty or non-string-keyed map.
@spec lock_execution( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.execution_id(), (-> result) ) :: {:ok, result} | {:error, StatifierPersistence.Storage.Adapter.error()} when result: term()
Runs fun under this adapter's per-execution mutual exclusion for execution_id
(the optional StatifierPersistence.Storage.Adapter.lock_execution/3).
Acquisition is an insert-if-absent on the Agent's lock table, one atomic
Agent.get_and_update/2 transition; contention spins with a small
bounded sleep (5ms) between attempts. The lock is
released in an after block, so any exit from fun - a raise included -
releases it; the raise itself propagates to the caller.
Simple and honest for a reference adapter. A production adapter should prefer its backend's native lock - the Ecto adapter implements this as a transaction-scoped advisory-plus-row lock (ADR-0004 decision 5 as amended 2026-08-22).
@spec retire_chart( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.content_hash(), StatifierPersistence.Storage.Adapter.retirement() ) :: {:ok, StatifierPersistence.Storage.Adapter.retired_info()} | {:error, StatifierPersistence.Storage.Adapter.error()}
Retires the chart on content_hash (the optional
StatifierPersistence.Storage.Adapter.retire_chart/3, ADR-0012
decisions 5 and 6).
The counts and the tombstone are one Agent.get_and_update/2, which
is this adapter's whole answer to the callback's atomicity contract:
the Agent serves one message at a time, so nothing can be created on
the hash between the counting and the write.
A refused retirement returns the state unchanged, and the refusal carries every count - the five execution arms, the durable-child pins, the position rows, and each source's own counts - while only ADR-0012 decision 1's blocking set causes one.
The tombstone keeps the entry and its content hash and nulls both
blobs, which is what makes the retired arm of fetch_chart/2
answerable at all.
@spec save_chart( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.chart_record() ) :: :ok | {:error, StatifierPersistence.Storage.Adapter.error()}
Stores chart_record under its content_hash, idempotent on repeat
writes of the same hash.
A tombstoned hash is refused with {:error, {:chart_retired, info}}
and is not revived (ADR-0012 decision 6). The read of the tombstone
and the write are one Agent.get_and_update/2, which is this
adapter's transaction.
@spec save_position( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.position_record() ) :: :ok | {:error, StatifierPersistence.Storage.Adapter.error()}
Stores position_record under its session_id, overwriting any position
already stored for that session.
@spec supports_chart_retirement?(StatifierPersistence.Storage.Adapter.opts()) :: boolean()
Declares chart retirement (the optional
StatifierPersistence.Storage.Adapter.supports_chart_retirement?/1,
ADR-0012 decision 6): an Agent's map has no NOT NULL to drop, so
the backend limit V07 records on a database adapter has no
counterpart here.
@spec supports_content_hash_query?(StatifierPersistence.Storage.Adapter.opts()) :: boolean()
Declares the drained query (the optional
StatifierPersistence.Storage.Adapter.supports_content_hash_query?/1):
this adapter holds every execution record in one map and can count them.
@spec supports_execution_outcome?(StatifierPersistence.Storage.Adapter.opts()) :: boolean()
Declares outcome support (the optional
StatifierPersistence.Storage.Adapter.supports_execution_outcome?/1): this
adapter keeps the blob on the execution record like every other field.
@spec supports_metadata?(StatifierPersistence.Storage.Adapter.opts()) :: boolean()
Declares metadata support (the optional
StatifierPersistence.Storage.Adapter.supports_metadata?/1): this
adapter stores the map with the execution record and returns it verbatim
(ADR-0006 decision 3).
@spec supports_retired_info?(StatifierPersistence.Storage.Adapter.opts()) :: boolean()
Declares the narrow tombstone read (the optional
StatifierPersistence.Storage.Adapter.supports_retired_info?/1).
@spec supports_tree_migration?(StatifierPersistence.Storage.Adapter.opts()) :: boolean()
Declares the tree migration unit (the optional
StatifierPersistence.Storage.Adapter.supports_tree_migration?/1,
ADR-0015 decision 3).
@spec update_execution( StatifierPersistence.Storage.Adapter.opts(), StatifierPersistence.Storage.Adapter.execution_record() ) :: :ok | {:error, StatifierPersistence.Storage.Adapter.error()}
Overwrites the execution stored under execution_record's execution_id with the full
record, or refuses with :execution_not_found when no execution exists for the id.
metadata is the documented exception to the full overwrite: it is
write-once (ADR-0006 decision 1 grants no way to change it after create),
so the stored map is carried forward and the given record's metadata
is ignored. outcome_blob is the second exception: a nil in the given
record carries the stored value forward, and a binary sets it.
@spec write_tree_migration(StatifierPersistence.Storage.Adapter.opts(), [ StatifierPersistence.Storage.Adapter.tree_write() ]) :: :ok | {:error, StatifierPersistence.Storage.Adapter.error()}
Writes a tree migration's re-pins and parks as one unit (the optional
StatifierPersistence.Storage.Adapter.write_tree_migration/2,
ADR-0015 decision 3).
Every write is applied to one copy of the state inside one
Agent.get_and_update/2, which is this adapter's transaction: the copy
replaces the state only when every write applied, and a write naming an
execution that is not stored answers {:error, :execution_not_found}
with the state unchanged.