How to drive a chart that calls out

Copy Markdown View Source

This guide shows you how to run an execution whose chart calls out to your host: it invokes a service and waits for the answer. The example is the loan chart from How to persist an execution and resume it in another process, which invokes a hold check when a copy of a book is lent to a patron.

Executions steps an execution once. A chart that invokes a service is not finished when that step returns - it is waiting for an answer it cannot fetch for itself, and every host that has embedded this package has written the same loop on top. StatifierPersistence.Driver is that loop.

Before you start

The snippets below use two values the persist-and-resume guide makes: store and machine, the storage handle and the compiled loan chart you stored under its content hash (its "Store the compiled chart" step). You also need execution_id, the id you choose for the loan, such as "loan_01H8". The snippets use the Statifier.Invoke.Types and Statifier.Send.Types modules by their full names.

Build the driver

Give Driver.new/3 the store, the machine and the seams to your host:

driver =
  StatifierPersistence.Driver.new(store, machine,
    dispatch: fn type, params, _context -> MyApp.Holds.perform(type, params) end,
    effects: fn effect, _context -> MyApp.Timers.consume(effect) end,
    invoke_types: Statifier.Invoke.Types.new(types: ["myapp:check_holds"]),
    send_types: Statifier.Send.Types.from_send_types(%{"myapp:remind" => MyApp.Reminders}),
    serialization: {MyApp.ExecutionLock, MyApp.ExecutionLock}
  )

The two registrations, invoke_types: and send_types:, are given once, to new/3: the driver carries them inside initialize: on the create and stamps them on every step after it.

Create the execution and send it events

{:ok, execution, state} = StatifierPersistence.Driver.create(driver, execution_id)

{:ok, execution, state} =
  StatifierPersistence.Driver.send_event(
    driver,
    execution_id,
    Statifier.Event.external("loan.due")
  )

One call is one durable step, every effect through your effects: executor, every <invoke> through your dispatch: fun inside that same step, and then one further durable step per answer until the chart rests.

{:ok, donedata} from dispatch: answers done.invoke.<id>; {:error, failure} answers error.communication.invoke.<id> with Statifier.Session.failed_invocation/3's own reason/attempts/detail payload, and means permanently failed rather than "retry".

Both events are built field for field from the two doors Statifier.Session gives a handler-backed invocation's host, origin and origintype included, so the same chart sees the same event whether it runs in a session or out of storage. That is asserted rather than claimed: test/statifier_persistence/driver_session_conformance_test.exs answers one document both ways and compares the _event each chart saw.

An answer whose invocation the chart has since cancelled is dropped, per spec 6.4.3, and a chart whose answer re-arms its own call is bounded by max_turns: rather than driven forever.

Register guard functions

If a chart's cond and expr may call guard functions your host registers (statifier's :function_providers), give function_providers: to new/3 the same way. The driver stamps the list on the create and on every step, so a resumed execution evaluates them with no call to Statifier.MachineState.put_function_providers/2 of your own. A durable subchart child the driver creates does not inherit the list.

A list that does not resolve is refused with {:error, {:function_providers, reason}} before any lock, load or write. start_child_at/6, the fan-out door, refuses it in its own shape, {:refused, {:function_providers, reason}}, before any child is created.

Fan one invocation out over N children

A durable subchart is one child per <invoke>, created inside the parent's own step. An <invoke> that maps over a list is N of them - say, one child per copy on a patron's reading list - and N creates cannot hold the parent's exclusion, so the children are started afterwards: one call per child, from whatever job picks it up.

StatifierPersistence.Driver.start_child_at(driver, parent_execution_id, effect, index, count,
  policy: :all
)

effect is the resolved Statifier.Effect.Invoke (or the whole {:start_child, resolved, {:invoke, invoke}} instruction), index is the child's 0-based position, and count is N. The call is idempotent on the child's derived execution id, so a re-delivered start adopts the child it already created instead of making a second one. Scheduling those calls is a job runner's business, not this package's.

Each child then runs as an ordinary execution. When one reaches a terminal status its answer is stored on its own execution record, and a settlement section under the parent's exclusion asks - through an indexed status projection, never a listing of whole records - whether all N have. Only the settlement that finds them all terminal assembles the dense, index-ordered list and answers the parent's ordinary door, once:

[
  %{"index" => 0, "status" => "completed", "donedata" => %{"id" => "copy_1"}},
  %{"index" => 1, "status" => "failed", "failure" => %{"reason" => "on_hold", ...}},
  %{"index" => 2, "status" => "cancelled"}
]

Cancel the rest when one child fails

policy: :first_error cancels the rest as soon as one child fails: the started siblings through the cascading cancel, and the ones whose start job has not run yet through the child_canceller: seam, which is handed the parent execution id, the invocation id, and the indices with no execution. Both kinds read "cancelled" at their index in the same list.

Let a child fail on its own word

A child fails on its own word, with no host in the loop, by settling in a failure-classed final - a top-level <final> whose <donedata> carries the reserved key statifier_persistence:execution_status set to "failed":

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

That step is an ordinary successful one; the execution record takes :failed with the failure string "failed_final", and the whole <donedata> - tag included, alongside whatever else the final carries - reaches the parent's list verbatim. Macrostep-budget exhaustion is the other route to :failed, and the failure string is what tells the two apart. An unhandled error.* event is not a route: a chart that cannot continue stays :active until its author routes the error to a final. See StatifierPersistence.Executions for the full rule.

Before 0.12.0 that key was spelled statifier_persistence:run_status. Both spellings are read for one release: in 0.12.0 the new key wins where both are present and the old one logs a deprecation line, and 0.13.0 reads only statifier_persistence:execution_status (ADR-0011 decision 4).

Check your adapter can settle children

An adapter that cannot store a child's answer, or cannot answer the status projection, is refused at open - a child whose invocation could never be settled is not started. On the Ecto adapter both arrive with the V03 migration.