StatifierRouter.SendHandler (StatifierRouter v0.3.0)

Copy Markdown View Source

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)
end

The 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

reason()

@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.

refusal()

@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

delete_config()

@spec delete_config() :: :ok

Removes what put_config/1 installed in the calling process.

execution_target()

@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"

fetch_config()

@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.

handle_effect(config, effect, context)

@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.

put_config(config)

@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.

sending_execution()

@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).