StatifierRouter.Delivery (StatifierRouter v0.9.2)

Copy Markdown View Source

The delivery module StatifierRouter.Config names by default: one binding's delivery of one event, as one transaction on the host's repo (ADR-0003, section 1).

It has two doors onto one transaction. deliver/4 is the seam StatifierRouter.route/3 calls for a binding, and deliver_event/4 the one StatifierRouter.SendHandler calls for an execution-to-execution send (ADR-0006, section 2), which brings its own event and its own idempotency key and is otherwise settled here exactly as a binding's delivery is.

deliver/4 reads five options of the configuration, four of them required:

  • :store - the %StatifierPersistence.Storage{} executions are kept in. It must be built over the configuration's own :repo, so that StatifierPersistence.Executions.create/4 and StatifierPersistence.Executions.step/5 write through the delivery's transaction rather than opening their own (statifier_persistence's README, "Writing inside a caller's transaction").
  • :executor - the StatifierPersistence.Executor both doors hand their effects to.
  • :resolver - the StatifierRouter.Resolver, a module or an arity-2 fun, answering {content_hash, machine} or {:error, reason} for (scope, document): the chart a new execution of document starts on (ADR-0002, section 4). It is called only when the delivery is about to create an execution. The content_hash it answers is not carried anywhere: the machine is handed on, and statifier_persistence records the hash Statifier.Machine.identity/1 derives from that machine, which is the hash :chart_resolver is later asked for (ADR-0002, the 2026-09-20 Note).
  • :chart_resolver - (content_hash) -> {:ok, machine} or :error: the compiled chart an existing execution started on, which step/5 is handed. An existing execution keeps the chart it started on, so this is looked up by the content hash its record carries, never by its document.
  • :persistence_options - the per-call snapshot options both doors carry, :routes, :invoke_types and :send_types. They reach a step/5 beside the event and a create/4 inside its initialize:: Statifier.MachineState.new/2 is the one writer of the fields they set, and a create has no stored position to stamp (statifier_persistence's create/4 option docs, at statifier_persistence 9cd192b; its own driver places invoke_types: the same way).

What one delivery does

The transaction first claims (binding_id, message_id) with StatifierRouter.Dedupe.claim/4, its first write under every create mode. When the pair's row is present and unexpired, the delivery is a duplicate, and the transaction writes the ledger row and nothing else: no address row is read or written (ADR-0003, section 6). Past the claim, the binding's create decides the rest (ADR-0003, section 4):

  • :if_absent reads the address row for (scope, document, key). When there is none, it mints an execution id and inserts the row with it, an insert that inserts nothing on a conflict with the unique index and does not fail the transaction; when it inserted nothing, another delivery's row won the race, and a following statement reads that row (ADR-0003, section 3). For the row it inserted, it asks the resolver for the chart and calls create/4 under the minted id; for a row it read, it reads the execution's status.
  • :never reads the address row and never inserts one. When there is none, the outcome is {:dropped, binding_id, :no_execution}: nothing is created or stepped, and the ledger row and the claim's dedupe row are the delivery's only writes. For a row it read, it reads the execution's status, as :if_absent does.
  • :always_new neither reads nor writes an address row (ADR-0002, section 7). It mints an execution id, asks the resolver for the chart and calls create/4 under that id, for every delivery; the minted id is the only handle on the execution.

Then it calls step/5 with the event, unless the execution is terminal, writes the ledger row (ADR-0004, section 4) and commits.

The outcomes are {:created_and_delivered, binding_id, execution_id} when this delivery created the execution, {:delivered, binding_id, execution_id} when it stepped one that existed, {:duplicate, binding_id} when the claim found the pair already handled, {:dropped, binding_id, :no_execution} for a :never binding whose address has no row, and {:dropped, binding_id, :finished} when the execution was terminal: read terminal before the step, created already terminal, or answered {:discarded, execution} by step/5 (ADR-0004, section 3). A binding's delivery whose step/5 took the event and selected no transition for it - the state step/5 answers carries last_selection: :none - is {:dropped, binding_id, :unmatched_event}, on the delivered path and on the created path alike, and its ledger row names the execution (ADR-0004, the Note of 2026-09-25). The execution's input log holds the event all the same, because step/5 appended it. deliver_event/4 does not take this outcome: a send the receiving execution does not take is delivered or created_and_delivered as before. A terminal sighting through an address row stamps the row's terminal_seen_at when it is still empty (ADR-0002, section 5); the row itself is left in place, and removing it is StatifierRouter.Addresses.reap/2's. Every outcome but the duplicate commits the dedupe row the claim wrote, a drop included, and a duplicate's ledger row carries the key and no execution id (ADR-0004, section 4).

Nothing here writes the input log: step/5 appends the event it steps (ADR-0003, section 1).

The create and step hooks

StatifierRouter.Config's :on_create and :on_step stand in for the two persistence calls above (ADR-0003, the Amendment of 2026-09-25). When one is set, the delivery calls it where it would have called create/4 or step/5, with the same arguments, inside the same transaction and savepoint, and reads its answer as it reads persistence's: the execution's status decides a finish, the state's last_selection decides an unmatched event, a {:discarded, execution} from :on_step is a finish, and an {:error, reason} from either rolls the delivery back to its savepoint and is returned. An answer outside the contract raises ArgumentError. When neither is set, the delivery calls statifier_persistence itself.

The completion hook

StatifierRouter.Config's :on_complete names a registered route an execution's donedata is handed to on the delivery that finishes it. It fires from the call that produced the termination - the create/4 or the step/5 whose answer carries a terminal execution - and from no other, because that answer is the only place the donedata exists: StatifierPersistence.Execution.from_record/1's own documentation says a stored record carries none (its ADR-0008 decision 3), so it sets the field to nil on every struct built from a row. A delivery to an execution that was already terminal answers {:dropped, binding_id, :finished} and never reaches the hook.

The route is handed a done.execution event whose data is the donedata verbatim - :undefined, statifier's no-value marker, for a <final> that carries none - and whose origin is the execution id, under an idempotency key of that execution id, the counters the answering state reports, and no ordinal. It runs inside this delivery's transaction like any other route, and an {:error, reason} from it settles the delivery as {:error, {:on_complete, route_name, reason}}.

What a route may not do while a delivery runs

A route called at the executor seam runs inside this transaction, under the execution's lock, and ADR-0005 decision 5 forbids it to call back into the sending execution: a nested step would run from the position the outer step has not written yet and would then be overwritten by it. deliver/4 refuses while StatifierRouter.SendHandler.sending_execution/0 names an execution, answering {:error, {:reentrant_route, execution_id}} before it opens anything, so a route that calls StatifierRouter.route/3 steps nothing and writes nothing. A route that calls StatifierPersistence.Executions.step/5 directly reaches past this door; the record forbids that call and this package has no guard for it. statifier_persistence 0.21.0 and later refuse it themselves: every door of StatifierPersistence.Executions that takes an execution id answers {:error, {:reentrant_step, execution_id}} when called for an execution whose executor is running in the calling process, as the sending execution's is here, before it reads or writes anything (that package's ADR-0004 Amendment of 2026-09-26). On an earlier statifier_persistence nothing refuses the call.

The scope a delivery runs under is also set for the length of the call, because the executor seam's context carries an execution id and a content hash and no scope, and a per-scope route override needs one. The seam runs in this same process, inside this transaction.

An {:error, reason} from create/4, step/5 or StatifierPersistence.Storage.fetch_execution/2, {:error, {:unresolved_document, document, reason}} when the resolver answers {:error, reason}, and {:error, {:chart_not_resolved, content_hash}} when the chart resolver answers :error, roll this delivery's writes back and are returned. Both doors do that the same way: the delivery runs inside an SQL savepoint of its own, an error rolls back to that savepoint, and the reason is returned as an ordinary value, with no Ecto.Repo.rollback/1 anywhere (ADR-0003, the Amendment of 2026-09-23). When the door opened the transaction itself, that leaves it holding nothing of the delivery's, which is ADR-0003, section 1's guarantee; when it was called inside a caller's transaction, it undoes the delivery's writes and nothing of the caller's. deliver_event/4's documentation says why a rollback cannot do this. A raise propagates; nothing here rescues it, and it takes an enclosing transaction with it. Effects the executor was handed before a rollback stay fired (ADR-0003, section 2).

The BasicHTTP location

On a configuration that sets :basichttp, the address row an :if_absent insert writes gets a location: a token StatifierRouter.BasicHTTP mints, inserted into the location table in the same transaction and savepoint, before create/4, and handed to that create in the snapshot's StatifierRouter.BasicHTTP registration, so the new execution's _ioprocessors names it (ADR-0002, the Amendment of 2026-09-30, decisions 1 and 4). Both doors do it, so an execution-to-execution send's create gets one too. A read address row, an :always_new create and a :never miss mint nothing. Without the key the delivery writes exactly what it wrote before.

The execution id

By default the execution id is a UXID with the prefix ex, minted by UXID.generate!/1; nothing is derived from the address (ADR-0002, section 3). When StatifierRouter.Config's :execution_id is set, the delivery mints through it instead, at the same two places - the address row :if_absent inserts and the create :always_new makes - handing it the delivery's scope, the document and the key, and the answer is the id the address row, create/4 and the ledger row carry (ADR-0002, the Amendment of 2026-09-25). An answer that is not a non-empty string raises ArgumentError; a raise from the callback propagates. A duplicate, a read address row and a :never drop mint nothing, so they never call it.

Summary

Types

The message one delivery carries: the event stepped into the execution, the id its dedupe row and its ledger row are written under, the scope it runs in and the time its rows carry.

What one delivery settles, past the event itself: the name its ledger row and its dedupe claim are written under, the document its address names, the create mode a miss follows, and the dedupe horizon that claim uses.

Functions

Delivers one event for one binding under key, as the module documentation describes, and answers with the outcome or {:error, reason}.

Delivers one prebuilt event under plan, in the same transaction and the same order deliver/4 uses, and answers with the same outcomes.

Types

envelope()

@type envelope() :: %{
  :event => Statifier.Event.t(),
  :message_id => String.t(),
  :scope => String.t(),
  :now => DateTime.t(),
  optional(atom()) => term()
}

The message one delivery carries: the event stepped into the execution, the id its dedupe row and its ledger row are written under, the scope it runs in and the time its rows carry.

plan()

@type plan() :: %{
  :id => String.t(),
  :document => String.t(),
  :create => :if_absent | :never | :always_new,
  :dedupe => %{by: :message_id, horizon_ms: pos_integer()},
  optional(atom()) => term()
}

What one delivery settles, past the event itself: the name its ledger row and its dedupe claim are written under, the document its address names, the create mode a miss follows, and the dedupe horizon that claim uses.

A StatifierRouter.Binding.t/0 is one of these, and it is the one deliver/4 builds. The other is ADR-0006's execution target: the reserved name execution, the document param the chart wrote, the create param it wrote, and ADR-0001, section 1's default horizon, since no binding supplies one there.

Functions

deliver(config, binding, key, delivery)

Delivers one event for one binding under key, as the module documentation describes, and answers with the outcome or {:error, reason}.

deliver_event(config, plan, key, delivery)

@spec deliver_event(StatifierRouter.Config.t(), plan(), String.t(), envelope()) ::
  StatifierRouter.outcome() | {:error, term()}

Delivers one prebuilt event under plan, in the same transaction and the same order deliver/4 uses, and answers with the same outcomes.

This is the door ADR-0006, section 2 names: an execution-to-execution send resolves its address, gets or creates its execution and writes its dedupe and ledger rows through this one function, so neither the unique index race nor the ledger has a second implementation here. Its caller owns three things deliver/4 owns for a binding: it builds the event (ADR-0006, section 4 gives an execution-to-execution send the engine builder rather than Statifier.Event.external/2), it composes the message_id, and it decides whether a scope override is in force. StatifierRouter.SendHandler is that caller.

The reentrancy refusal deliver/4 opens with is not repeated here. It guards the route door, and an execution-to-execution send never enters a route: StatifierRouter.SendHandler branches on the reserved target name before it marks a route as running.

This door takes a savepoint, and it does not roll back

deliver/4 settles the same way, for the same reason: route/3 is usually the outermost transaction on its path, but nothing stops a host from calling it inside its own. This door is never outermost at the seam: it is called inside the sending execution's own step/5, which statifier_persistence has already opened a transaction for. A Ecto.Repo.rollback/1 there would take the sender's transaction with it, answer a bare :rollback in place of the reason, and raise DBConnection.ConnectionError out of the sending step's persist tail. And mode: :savepoint does not prevent it: DBConnection.transaction/3 ignores its options once the connection is already in a transaction (db_connection 2.10.2, the conn_mode: :transaction clause), so a nested Ecto.Repo.transaction/2 gets no savepoint to roll back to.

That outcome is the opposite of what the records require: ADR-0005, section 7 has the handler's {:error, reason} leave the sending step standing, and ADR-0006, section 3 rests on it. So this door rolls nothing back. It brackets the delivery in an explicit SQL savepoint of its own and answers the reason as an ordinary return:

  • on an outcome, the savepoint is released and the delivery's writes stand with the rest of the enclosing transaction;
  • on a delivery-level error - an unresolved document, a chart that does not resolve, a refusal from create/4, step/5 or StatifierPersistence.Storage.fetch_execution/2 - the transaction rolls back to that savepoint, which undoes every write this delivery made on the target side (the dedupe row it claimed, the address row it inserted, the execution it created) and nothing else, and {:error, reason} carrying the real reason is returned.

The savepoint's name is minted from System.unique_integer/1, never from anything a caller supplies, and a nested delivery gets its own. The host's :repo therefore has to answer query!/1 as well, which every Ecto.Adapters.SQL repo does; this package's tables are Postgres already.

A raise is the one case that still reaches the enclosing transaction, exactly as it does on the binding path: nothing here rescues, and the record leaves a raise as a raise.