This guide shows you how to do two things the Ecto adapter supports beside the core store: tag an execution with your own scope ids and list executions by them, and keep a log of the events an execution was handed so it can be replayed. The example is a library loan: one execution per loan of a copy to a patron, and a circulation screen that lists every loan for one branch. Both parts assume a store built as in How to set up the Ecto adapter.
List executions by host scope
An execution record carries engine identities and opaque blobs. Nothing on it
answers the question a multi-tenant host asks first - "list the executions for
scope X" - so an execution can carry one optional, opaque metadata map,
stored beside it and handed back unchanged (ADR-0006).
Tag the execution at create
Tag the execution with the scope ids your host already keys its own tables by:
{:ok, execution, _machine_state} =
StatifierPersistence.Executions.create(store, "loan_01H8", machine,
executor: MyApp.Executor,
metadata: %{
"tenant_id" => "lib_01H8X",
"branch_id" => "branch_4471"
}
)List by an equality match
Read the executions back with an equality match on every pair:
{:ok, executions} =
StatifierPersistence.Storage.Ecto.list_executions_by_metadata(store.opts, %{
"branch_id" => "branch_4471"
})Equality on all given pairs is the whole query surface: no ranges, no partial
matches, no ordering guarantee. Anything richer is a query you write against
your own column - the table name is yours to configure, so that is a supported
thing to do. The V02 migration adds the column as nullable jsonb with no
index of its own, because which pairs you query by is your call. V03 (see
Upgrading a host from 0.13 to 0.25.0) adds the one
containment index this package's own settlement query needs, a GIN
jsonb_path_ops index on the whole column; an expression index on particular
keys, or the wider jsonb_ops operator set, is still yours to add when the
volume asks.
Keep identities in the map, never personal data
Keys and values are host identities - a tenant id, a branch id, a correlation
id - and never a patron's name, an email address, a postal address, a card
number (a library card or a payment card), or any other personal data. This is
a rule of the contract, not advice: :blob_type encryption covers the payload
columns (identity_blob, chart_blob, position_blob, outcome_blob, and
the input log's input_blob) and does not reach this one, so anything you file
here is at rest in the clear no matter how the blobs are configured. The map is
opaque to this package by design, so nothing here can inspect a value and
reject it - the rule is kept by you.
Handle an adapter that refuses the map
An adapter that cannot store the map refuses a non-empty one at the create with
{:error, :metadata_unsupported}, so you learn on the first call rather than
finding a silently dropped scope later. An empty or absent map is never
refused. The shipped in-memory and Ecto adapters both support it; a third-party
adapter that does not is still conformant, and the conformance suite tests both
answers. The Ecto adapter refuses at the same point for a value jsonb cannot
hold - a tuple, an atom, a pid, or a binary that is not valid UTF-8 - rather
than storing something that is not what you handed it. The map is write-once:
it is set at create and a later step or abandonment carries it forward
untouched.
Scope the charts yourself
An execution is the only thing this package scopes for you, and only through
that map. A chart is not: StatifierPersistence.Storage.save_chart/3 keys a
chart on its content hash alone, so two branches storing byte-identical charts
share one chart row. Tenant-qualify your own per-chart rows in your own tables
- folding a namespace into the hash would change what a chart's identity is, which is statifier-ex's contract and not an option this package offers.
Record an execution's inputs for replay
A durably stepped execution stores a chart, a position and an execution record, and none of them is an input. The position is the execution's current configuration, overwritten on every step, so by construction the history an offline replay needs is destroyed by the mechanism that makes the execution durable. An optional per-execution input log closes that gap (ADR-0010).
Ask whether the adapter keeps a log
The log is opt-in by export, exactly as the metadata map is: an adapter that
exports supports_input_log?/1 and answers true keeps one, and an adapter
that does not stores no inputs and behaves exactly as it did before. Nothing
refuses an execution over it - a diagnostic facility must not break the
execution it is diagnosing - so ask before you rely on it:
StatifierPersistence.Storage.input_log_supported?(store)Read the log
The Ecto adapter keeps the log on every backend, in the V05 table. Each entry
is the %Statifier.Event{} the interpreter was handed, verbatim, stamped with
the public door it entered by and a dense zero-based ordinal:
{:ok, entries} = StatifierPersistence.Executions.inputs(store, "exec_1")
Enum.map(entries, &{&1.seq, &1.door, &1.event.name})
#=> [{0, "step", "advance"}, {1, "answer_parent", "done.invoke.call"}]One log belongs to one execution. A durable subchart's child is an ordinary
execution, so it has its own log; the parent's holds the answer it saw at the
answer_parent door, and not the child's inputs. Only inputs an interpreter
actually saw are recorded - a delivery to a terminal execution, or to an
invocation the chart has since cancelled, is discarded and appends nothing,
because a replay that applied it would produce a different execution than the
one that happened.
Decide to turn it on as a retention choice
Turning the log on is a data-retention decision, not a debugging switch. Chart,
position and identity blobs are engine-shaped; an event's data is your own
values, and this is the first thing this package stores that can hold personal
data, such as a patron's contact details or a card number. So input_blob is a
blob in the :blob_type sense (see How to encrypt the blob columns and place
host columns), and
it is not the metadata map, which stays in the clear by design.
Cap how much accumulates
Bound how much accumulates with a per-execution cap declared where every other adapter setting is:
{:ok, store} =
StatifierPersistence.Storage.new(
StatifierPersistence.Storage.Ecto,
persistence: MyApp.Persistence,
input_log_cap: 500
)The default is :infinity; a bounded default would be this package silently
truncating your log. Past the cap the log closes itself: the last slot is
written as a marker entry whose event is nil, every later append is
refused, and the step itself succeeds and the execution carries on. The marker
is the point - a truncated log that looked complete would satisfy every check a
replay makes while replaying an execution that never happened.
Replay the log
The replay itself is statifier_ui's (StatifierUI.Trace.Replay.from_events/4);
ADR-0010 decision 8 names the mapping from a stored entry to what that function
takes, and nothing in this package depends on statifier_ui to say so.