The one module that serves both shapes a registered type's <send>
reaches a host in (ADR-0005, decision 5). It implements
Statifier.Send.Processor for a live Statifier.Session, and it offers
handle_effect/3 for a process-less host to call from
StatifierPersistence.Executor.execute/2. Both entry points compose the
same key, resolve the same route name, and reach the same adapter, so
there is no second implementation to keep in step.
A send is this handler's when its type is the configuration's
:send_type; an effect of any other type is ignored, because the host's
executor sees every effect the lifecycle does not consume itself. The
route name is the send's target, which the engine never parses.
The plan/perform split, on the send-processor shape
Statifier.Send.Processor.deliver/3 and
Statifier.Send.Processor.cancel/2 are pure planning callbacks,
called with no process, no clock and no I/O. They compose the key and
return one {:handler, __MODULE__, payload} instruction;
Statifier.Send.Processor.perform/2 is the impure half and calls the
adapter. perform/2 MAY be called more than once for the same send, so
a route owes at-most-once on the key rather than this module owing
exactly-once.
A miss on that shape comes back as perform/2's {:error, reason}.
Reporting it through Statifier.Session.failed_send/3 is the host's,
which is what that function's own documentation requires: it is called
by the host, never by deliver/3 or cancel/2.
Where the configuration comes from on each shape
handle_effect/3 is handed the configuration, because the host builds
the executor that calls it:
executor: fn effect, context ->
StatifierRouter.SendHandler.handle_effect(config, effect, context)
endThe Statifier.Send.Processor callbacks are handed no configuration -
the session registers a bare module and the plan context carries only
session_id - so on that shape the host installs the configuration in
the process that performs the instructions, with put_config/1. That is
the process the host's own session and executor run in; this package
does not start sessions.
The idempotency key, and the cancellation key
The key handed to a route is StatifierRouter.Route.idempotency_key/0
(ADR-0005, decision 4): the scope half, where in the step the send sat,
and the ordinal. The scope half is execution_id from the seam context
at the executor seam and session_id from the plan context on the
send-processor shape.
The durable timer queue is keyed on something else and smaller -
{scope, send_id}, st-ADR-0054's cancellation key - and the composed
key rides beside the row as the dedup key. StatifierRouter.TimerQueue
says why the two are two keys.
Delayed sends
On the process-less shape a %Statifier.Effect.SendDelayed{} is
recorded on the configuration's :timer_queue, and a
%Statifier.Effect.Cancel{} deletes that scope's rows for its send id.
Nothing is scheduled in memory: this package resumes on every delivery,
and a live session's holds are not part of Statifier.Position.
On the send-processor shape this handler writes no queue row, which is
what ADR-0005 decision 5 says: only the process-less shape writes the
durable queue. It also holds no timer, because it is not a process. A
delayed send on that shape is therefore refused by perform/2 rather
than dropped, and the refusal reaches the chart through the host's
Statifier.Session.failed_send/3 like any other miss. ADR-0005 decision
5 and Statifier.Send.Processor's moduledoc do not agree about who
holds that timer - the record leaves it with the live session, the
behaviour says the session schedules nothing and the processor owns the
delay - and resolving that disagreement is a record's work, not this
module's.
What a route may not do from the executor seam
A route called at the executor seam runs inside the delivery's
transaction, under the execution's lock, so it must only hand off
durably and must never re-enter the sending execution (ADR-0005,
decision 5). sending_execution/0 names the execution a route is
running under, and StatifierRouter.Delivery.deliver/4 refuses for as
long as it is set: a route that calls StatifierRouter.route/3 is
answered {:error, {:reentrant_route, execution_id}} and nothing is
stepped. That refusal reaches this package's own door only. A route that
calls StatifierPersistence.Executions.step/5 directly reaches past it,
and no reentrancy guard exists there; the record forbids that call, and
this package cannot enforce it.
The execution target
One target name is reserved, execution_target/0's (ADR-0006, section
1). A send that writes it is not handed to a route at all: it names
another durable execution by the document and key params the chart
wrote, under the sending execution's own scope, and is delivered through
StatifierRouter.Delivery.deliver_event/4 - the same transaction, the
same get-or-create, the same dedupe row and the same ledger row an
inbound delivery uses.
<send type="myapp:router" target="execution" event="pair.joined">
<param name="document" expr="'placement_counter'"/>
<param name="key" expr="placement"/>
</send>The scope is never a param: it is read from the sending execution's own
address row (StatifierRouter.Addresses.by_execution/2), so a chart can
address only inside the scope it runs in. An execution with no address
row - what :always_new produces - has no scope, and its send is
refused as unaddressed_sender, the one refusal with no ledger row,
because the ledger's scope is NOT NULL (ADR-0006, section 6).
The branch is in handle_effect/3, beside mine?/2 and before
hand_off/3, and that placement is load-bearing rather than tidy.
hand_off/3 marks a route as running for the length of the dispatch,
and StatifierRouter.Delivery.deliver/4 refuses while that mark is set
(ADR-0005, decision 5). That refusal protects the sending execution's
own position; ADR-0006 needs a step on a different execution inside
the sender's transaction, which decision 5 never forbade, and section 6
refuses the one case it would collide with - a send to the sender's own
address - for that very lock reason. Branching before hand_off/3 keeps
the two mechanisms apart rather than narrowing the guard.
A refusal and a miss are reported to the sender the way ADR-0005,
section 7 reports an unregistered route: {:error, reason} from this
handler, which at the executor seam re-enters the sending execution as
error.communication carrying the send's sendid and does not roll its
step back. {:send_refused, reason} carries one of section 6's five
reasons and {:send_undelivered, why} a dropped: no_execution or a
dropped: finished. Four of the five refusals also write one
send_refused ledger row, whose reason column holds the record's own
word for it: document, key, create or self_address. The row
records that the sender was told; it does not stand in for telling it.
The unregistered route
When the send's target names no registered route the lookup misses,
and the handler answers {:error, {:unregistered_route, name}}. At the
executor seam that return does not roll the step back, deliberately: the
executor failure is deferred, re-entered as error.communication
carrying the send's sendid, and the execution is written anyway. The
chart hears that its send did not go; the step it just took stands.
ADR-0005 section 7 also has the handler record that refusal on the
routing ledger, and it writes one. What the row's columns hold was
ruled rather than minted here, and the ruling extends ADR-0006,
section 6's send convention rather than opening a second one:
binding_id is the reserved name execution, which is what tells an
outbound send's row from an inbound delivery's; message_id is
ADR-0005, section 4's composed key, written out as every other row on
this path writes it; outcome is send_refused; and reason is one
added word, route, naming the target that resolved to no registered
route. key and execution_id stay empty, as they do for every
refusal discovered before a target is resolved. scope is the host's
partition, read from the sending execution's own address row as
ADR-0006, section 1 reads it, so a sender with no address row is
reported and not recorded, the same gap section 6 names for
unaddressed_sender and for the same reason: the ledger's scope is
NOT NULL.
The lookup is on the scope half of the composed key, whatever the shape
put there, and on nothing else: StatifierRouter.Addresses.by_execution/2
is asked for that value's address row. Which shape a refusal came in on
is not what decides whether a row is written - a scope half that names
an address row is recorded, one that names none is reported only. On
the send-processor shape the scope half is the sender's session id, and
whether that finds a row is the host's arrangement rather than this
package's guarantee: ADR-0006, section 4 holds that at this package's
seam the sender's session id is its execution id, so a host that keeps
them the same is recorded on that shape too.
The row records that the sender was told and does not stand in for
telling it: the return is {:error, {:unregistered_route, name}} on
both shapes, unchanged, and the sending step still commits.
The write is bracketed in a SQL savepoint of its own, for the reason
StatifierRouter.Delivery.deliver_event/4 documents at length: this
handler runs at the executor seam inside the sending execution's own
transaction, and a failed insert there leaves that transaction aborted,
which would take the sender's step down with it. An insert that fails
rolls back to that savepoint and nothing else, and the miss is reported
either way.
The bracket is not an absolute, and the code does not pretend it is.
Only the insert sits inside the guard, so a release that raises after a
successful insert cannot turn into a rollback of the row it just wrote;
that release's own failure is swallowed. What is left uncovered is the
savepoint statements themselves: a connection that has gone away raises
out of SAVEPOINT or out of the rollback, and that raise stands and
reaches the sender, because a bracket cannot settle a transaction it
can no longer speak to. What the bracket buys is that a ledger row this
package could not write is not itself the thing that takes the sender
down.
Summary
Types
Why this handler did not hand a send off.
Why an execution-to-execution send was refused (ADR-0006, section 6).
unaddressed_sender is the one of the five that writes no ledger row.
Functions
Removes what put_config/1 installed in the calling process.
The one target name ADR-0006, section 1 reserves for the execution
target. A host may register no route under it and give no binding this
id; StatifierRouter.Config.new/1 refuses both.
The configuration put_config/1 installed, or
{:error, {:no_config, __MODULE__}} when the host installed none.
Handles one effect at StatifierPersistence.Executor.execute/2, with
that seam's context. An effect whose type is not the configuration's
:send_type, and every effect that is not a send, a delayed send or a
cancel, is ignored.
Installs the configuration the Statifier.Send.Processor callbacks
serve, in the calling process. handle_effect/3 is handed its own and
needs none.
The execution a route is running under in the calling process, or nil.
StatifierRouter.Delivery.deliver/4 refuses for as long as it is set: a
route called at the executor seam may not re-enter the sending execution
(ADR-0005, decision 5).
Types
@type reason() :: {:unregistered_route, String.t() | nil} | {:no_timer_queue, String.t() | nil} | {:delayed_send_unsupported, String.t() | nil} | {:no_config, module()} | {:send_refused, refusal()} | {:send_undelivered, :no_execution | :finished} | term()
Why this handler did not hand a send off.
@type refusal() :: :unaddressed_sender | :document | :key | :create | :self_address
Why an execution-to-execution send was refused (ADR-0006, section 6).
unaddressed_sender is the one of the five that writes no ledger row.
Functions
@spec delete_config() :: :ok
Removes what put_config/1 installed in the calling process.
@spec execution_target() :: String.t()
The one target name ADR-0006, section 1 reserves for the execution
target. A host may register no route under it and give no binding this
id; StatifierRouter.Config.new/1 refuses both.
iex> StatifierRouter.SendHandler.execution_target()
"execution"
@spec fetch_config() :: {:ok, StatifierRouter.Config.t()} | {:error, reason()}
The configuration put_config/1 installed, or
{:error, {:no_config, __MODULE__}} when the host installed none.
@spec handle_effect( StatifierRouter.Config.t(), Statifier.Effect.t(), StatifierPersistence.Executor.context() ) :: :ok | {:error, reason()}
Handles one effect at StatifierPersistence.Executor.execute/2, with
that seam's context. An effect whose type is not the configuration's
:send_type, and every effect that is not a send, a delayed send or a
cancel, is ignored.
@spec put_config(StatifierRouter.Config.t()) :: :ok
Installs the configuration the Statifier.Send.Processor callbacks
serve, in the calling process. handle_effect/3 is handed its own and
needs none.
@spec sending_execution() :: String.t() | nil
The execution a route is running under in the calling process, or nil.
StatifierRouter.Delivery.deliver/4 refuses for as long as it is set: a
route called at the executor seam may not re-enter the sending execution
(ADR-0005, decision 5).