StatifierPersistence.Storage.Adapter behaviour (StatifierPersistence v0.1.0)

Copy Markdown View Source

The storage contract: opaque blobs keyed by engine identities.

An adapter stores and returns binaries plus engine identity strings, and nothing else (ADR-0003 decision 1). No callback receives a compiled Statifier.Machine and none returns a Statifier.MachineState value - the identity guard is not a callback here, and cannot be, because no callback ever holds both the stored identity and a caller-supplied machine at the same time. The guard lives above every adapter, in StatifierPersistence.Storage.load_position/3 (ADR-0003 decision 2).

A chart is keyed by its content hash (Statifier.Machine.Identity.content_hash, verbatim); a position is keyed by the engine session id (st-ADR-0008's sess_ UXID), also verbatim; a run is keyed by a caller-supplied opaque run_id, also verbatim (ADR-0004 decision 2). All are opaque strings to this layer: no callback here accepts or returns a surrogate key, a table name, or a prefix (ADR-0002 decision 1, ADR-0003 decision 3).

Summary

Types

A stored chart: its content hash, its identity envelope (Statifier.Machine.Identity.to_binary/1), and an opaque chart_blob this layer does not decode, inspect, or say what produced (ADR-0003 decision 1).

A chart's content hash, verbatim from Statifier.Machine.Identity (ADR-0002 decision 1).

This layer's own refusal arms. :chart_not_found, :position_not_found, and :run_not_found are the not-found arms every adapter must return instead of nil or a raise; :run_exists is insert_run/2's refusal of a duplicate run_id; {:adapter, term()} carries a backend failure (a database down, a timeout) that is not this layer's to interpret further.

Adapter configuration, opaque to this package. init/1 returns the value a caller then threads through every other callback as this adapter's handle - a pid, a name, a repo module, whatever the adapter needs.

A stored position: the engine session id it belongs to, the content hash and identity envelope of the chart it was saved against, and the opaque position_blob (Statifier.Position.to_binary/1's output) this layer does not decode.

A run's key: a caller-supplied opaque string, stored verbatim (ADR-0004 decision 2). A host identity in ADR-0002 decision 1's category - never a surrogate this layer generates.

A stored run (ADR-0004 decision 1): its caller-supplied key, its status, the content hash and identity envelope of the chart it runs, the opaque position_blob holding its current position - nullable, because a run that fails at creation has no quiescent position to store - and a short failure reason for a :failed run, nil otherwise.

A run's lifecycle status (ADR-0004 decision 2). :completed and :failed are terminal. No callback here validates a transition between them - the lifecycle above the facade owns that.

An engine session id (st-ADR-0008), verbatim.

Callbacks

Fetches the chart stored under content_hash.

Fetches the position stored for session_id.

Fetches the run stored under run_id.

Prepares this adapter for use and returns the handle every other callback is called with as its first argument.

Inserts run_record, refusing a duplicate run_id with {:error, :run_exists}.

Optional per-test isolation hook (ADR-0003 amendment, 2026-08-21).

Optional per-run lock (ADR-0003 amendment, 2026-08-22; ADR-0004 decision 5).

Stores chart_record, idempotent on its content_hash.

Stores position_record, overwriting any position already stored for its session_id.

Overwrites the run stored under run_record's run_id with the full record.

Types

chart_record()

@type chart_record() :: %{
  content_hash: content_hash(),
  identity_blob: binary(),
  chart_blob: binary()
}

A stored chart: its content hash, its identity envelope (Statifier.Machine.Identity.to_binary/1), and an opaque chart_blob this layer does not decode, inspect, or say what produced (ADR-0003 decision 1).

content_hash()

@type content_hash() :: String.t()

A chart's content hash, verbatim from Statifier.Machine.Identity (ADR-0002 decision 1).

error()

@type error() ::
  :chart_not_found
  | :position_not_found
  | :run_exists
  | :run_not_found
  | {:adapter, term()}

This layer's own refusal arms. :chart_not_found, :position_not_found, and :run_not_found are the not-found arms every adapter must return instead of nil or a raise; :run_exists is insert_run/2's refusal of a duplicate run_id; {:adapter, term()} carries a backend failure (a database down, a timeout) that is not this layer's to interpret further.

opts()

@type opts() :: keyword()

Adapter configuration, opaque to this package. init/1 returns the value a caller then threads through every other callback as this adapter's handle - a pid, a name, a repo module, whatever the adapter needs.

position_record()

@type position_record() :: %{
  session_id: session_id(),
  content_hash: content_hash(),
  identity_blob: binary(),
  position_blob: binary()
}

A stored position: the engine session id it belongs to, the content hash and identity envelope of the chart it was saved against, and the opaque position_blob (Statifier.Position.to_binary/1's output) this layer does not decode.

run_id()

@type run_id() :: String.t()

A run's key: a caller-supplied opaque string, stored verbatim (ADR-0004 decision 2). A host identity in ADR-0002 decision 1's category - never a surrogate this layer generates.

run_record()

@type run_record() :: %{
  run_id: run_id(),
  status: run_status(),
  content_hash: content_hash(),
  identity_blob: binary(),
  position_blob: binary() | nil,
  failure: String.t() | nil
}

A stored run (ADR-0004 decision 1): its caller-supplied key, its status, the content hash and identity envelope of the chart it runs, the opaque position_blob holding its current position - nullable, because a run that fails at creation has no quiescent position to store - and a short failure reason for a :failed run, nil otherwise.

run_status()

@type run_status() :: :active | :completed | :failed

A run's lifecycle status (ADR-0004 decision 2). :completed and :failed are terminal. No callback here validates a transition between them - the lifecycle above the facade owns that.

session_id()

@type session_id() :: String.t()

An engine session id (st-ADR-0008), verbatim.

Callbacks

fetch_chart(opts, content_hash)

@callback fetch_chart(opts(), content_hash()) :: {:ok, chart_record()} | {:error, error()}

Fetches the chart stored under content_hash.

Returns {:error, :chart_not_found} when no chart is stored under that hash - never {:ok, nil} and never a raise. The returned chart_blob and identity_blob must be byte-identical to what save_chart/2 was given; an adapter must not normalize, truncate, or re-encode them.

fetch_position(opts, session_id)

@callback fetch_position(opts(), session_id()) ::
  {:ok, position_record()} | {:error, error()}

Fetches the position stored for session_id.

Returns {:error, :position_not_found} when no position is stored for that session id - never {:ok, nil} and never a raise. The returned position_blob and identity_blob must be byte-identical to what save_position/2 was given.

fetch_run(opts, run_id)

@callback fetch_run(opts(), run_id()) :: {:ok, run_record()} | {:error, error()}

Fetches the run stored under run_id.

Returns {:error, :run_not_found} when no run is stored under that id - never {:ok, nil} and never a raise. The returned identity_blob and position_blob must be byte-identical to what was stored (a stored nil position_blob comes back as nil); an adapter must not normalize, truncate, or re-encode them, and it does not decode them either (ADR-0003 decision 1).

init(opts)

@callback init(opts()) :: {:ok, opts()} | {:error, error()}

Prepares this adapter for use and returns the handle every other callback is called with as its first argument.

For an adapter that needs setup - starting an Agent, checking a repo is reachable - this is the declared place for it, and the return value is the opts a caller threads through save_chart/2, fetch_chart/2, save_position/2, and fetch_position/2. An adapter needing no setup returns {:ok, opts} unchanged.

insert_run(opts, run_record)

@callback insert_run(opts(), run_record()) :: :ok | {:error, error()}

Inserts run_record, refusing a duplicate run_id with {:error, :run_exists}.

The refusal must be atomic with the write: no interleaving of two insert_run/2 calls for the same run_id may let both return :ok. Create-exactly-once rests on this callback alone, without a lock, so a check-then-insert implemented as two separate operations does not satisfy the contract - a SQL adapter reaches for a unique index (or equivalent backend-native uniqueness) and maps its violation to {:error, :run_exists}.

This callback does not decode position_blob or identity_blob, does not validate the status, and performs no identity check - the facade and the lifecycle own those (ADR-0003 decisions 1 and 2, ADR-0004 decision 1).

isolate(opts)

(optional)
@callback isolate(opts()) :: :ok | {:error, error()}

Optional per-test isolation hook (ADR-0003 amendment, 2026-08-21).

The conformance suite's case template (StorageConformance, in this package's Testing namespace) calls this before each generated test, when an adapter exports it, so an adapter backed by a shared resource - a database connection, a sandbox checkout - can wrap every test in its own isolated unit (an Ecto.Adapters.SQL.Sandbox checkout, for one) instead of leaking state between conformance tests. Optional and defaulted to a no-op by the template's own function_exported?/3 check: an adapter needing no isolation, like StatifierPersistence.Storage.InMemory, simply does not implement it.

lock_run(opts, run_id, function)

(optional)
@callback lock_run(opts(), run_id(), (-> result)) :: {:ok, result} | {:error, error()}
when result: var

Optional per-run lock (ADR-0003 amendment, 2026-08-22; ADR-0004 decision 5).

Provides mutual exclusion per run_id: while one lock_run/3 call for a given run_id is running fun, no other lock_run/3 call for the same run_id may run its own. fun runs while the exclusion is held, and the exclusion is released on ANY exit from fun - a normal return, a throw, and a raise escaping fun alike. The lock must not leak: a raising fun propagates to the caller, but the next lock_run/3 for that run_id must still acquire.

This is the callback the default serialization strategy (StatifierPersistence.Serialization.AdapterLock) delegates to; an adapter that does not export it makes that strategy refuse with {:error, {:serialization, :not_supported}}. The Ecto adapter implements it as a transaction-scoped advisory lock plus a SELECT ... FOR UPDATE row lock inside a transaction that spans fun (ADR-0004 decision 5 as amended 2026-08-22) - the advisory half exists because a row lock alone excludes nothing for a run_id whose run has not been inserted yet.

save_chart(opts, chart_record)

@callback save_chart(opts(), chart_record()) :: :ok | {:error, error()}

Stores chart_record, idempotent on its content_hash.

A content hash is a content address: saving the same hash twice is :ok and must not duplicate the row or change what a later fetch_chart/2 returns for it. This callback does not inspect, decode, or validate chart_blob's bytes and performs no identity check - both are outside this layer's job (ADR-0003 decisions 1 and 2).

save_position(opts, position_record)

@callback save_position(opts(), position_record()) :: :ok | {:error, error()}

Stores position_record, overwriting any position already stored for its session_id.

A session has exactly one current position; this layer keeps no history of prior saves. This callback does not decode or validate position_blob and performs no identity check, for the same reason save_chart/2 does not: the guard belongs to the facade, above every adapter (ADR-0003 decision 2).

update_run(opts, run_record)

@callback update_run(opts(), run_record()) :: :ok | {:error, error()}

Overwrites the run stored under run_record's run_id with the full record.

Returns {:error, :run_not_found} when no run exists for the id. This is a full-record overwrite - there is no partial-update surface, so every field in the stored row after this call is the given record's, including a nil position_blob. Like the other run callbacks it decodes nothing, validates no status transition, and performs no identity check - the facade and the lifecycle own those (ADR-0003 decisions 1 and 2, ADR-0004 decision 1).