Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ config :statifier_examples, StatifierExamples.Repo,
# package leaves to the host: `parcel_notices` carries the hand-off its
# one route makes, and `router_maintenance` the reapers. The recipe
# checks that both reapers are on this crontab.
#
# `desk_posts` carries the hold desk's BasicHTTP POSTs, each made after the
# delivery that planned it has committed (`StatifierExamples.HoldDesk.DeskPost`).
config :statifier_examples, Oban,
repo: StatifierExamples.Repo,
engine: Oban.Engines.Lite,
Expand All @@ -51,7 +54,8 @@ config :statifier_examples, Oban,
statifier_timers: 5,
statifier_invocations: 5,
parcel_notices: 1,
router_maintenance: 1
router_maintenance: 1,
desk_posts: 1
],
plugins: [
{Oban.Plugins.Cron,
Expand Down
74 changes: 54 additions & 20 deletions docs/guides/basichttp-front.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ path every routed event takes.

This guide walks the pieces as this app wires them:
`StatifierExamples.HoldDesk` (the router configuration, the chart's
resolver and the executor), `priv/library/hold_desk.scxml` (the chart),
resolver and the executor), `StatifierExamples.HoldDesk.DeskPost` (the job
that makes the outbound POST), `priv/library/hold_desk.scxml` (the chart),
`StatifierExamplesWeb.BasicHTTPController` (the front's action) and
`test/statifier_examples_web/controllers/basic_http_controller_test.exs`,
which drives the whole of it through the controller.
Expand All @@ -35,7 +36,7 @@ answers 404.

| Package | Version | What this guide uses it for |
|---|---|---|
| `statifier_router` | 0.9.2 | the `:basichttp` key, the location, `StatifierRouter.BasicHTTP.Front`, the location table |
| `statifier_router` | 0.9.2 | the `:basichttp` key, the location, `StatifierRouter.BasicHTTP.Front`, the location table, `deliver_event/4` for a failed send |
| `statifier` | 2.10.0 | the Basic HTTP Event I/O Processor and its decoder |
| `statifier_persistence` | 0.24.0 | the chart registry, the execution, the input log |

Expand Down Expand Up @@ -87,22 +88,53 @@ gives every router table.

A durable execution has no session to perform its sends: the step hands
each effect to the configuration's executor. `StatifierExamples.HoldDesk.execute/2`
plans a BasicHTTP send with `StatifierRouter.BasicHTTP.deliver/3` and
performs what it planned with the processor's `perform/2`, which POSTs a
form body to the desk with the send's `scxml-send-key` header. The POST
goes through the configuration's `:transport`: statifier's default,
on OTP's `:httpc`, in the dev app, and a transport under test that hands
the POST back to the test instead of sending it.

The POST is made inside the delivery's transaction, before the step
commits. A desk that does not answer 2xx, or does not answer at all, is a
failed send: `statifier_persistence` enters `error.communication`,
carrying the send id, into the execution in the same step, and the chart
takes it from `waiting` to its other final state, `desk_unreached`. A send
with no target, which statifier plans as an `error.communication` raise and
no request, fails without a POST and enters the execution the same way. A
delayed BasicHTTP send is refused the same way, because its timer would
live in the delivering process rather than in the database.
plans a BasicHTTP send with `StatifierRouter.BasicHTTP.deliver/3`, which
answers the POST to make - a form body for the desk, with the send's
`scxml-send-key` header - and makes none.

The executor runs inside the delivery's transaction, so it does not make
the POST there. It inserts a `StatifierExamples.HoldDesk.DeskPost` job on
this app's own Oban, in the `desk_posts` queue. The job writes through the
same repo, so it commits with the step that sent the POST and a delivery
that rolls back takes the job with it: the jobs table is the outbox. The
job is unique on the send's dedup key written out, so a step that is
driven again, and re-emits the same send, inserts no second job.

The job performs the POST after the delivery has committed, with the
processor's `perform/2`, through the configuration's `:transport`:
statifier's default, on OTP's `:httpc`, in the dev app, and a transport
under test that hands the POST back to the test instead of sending it.
This is why the example performs after the commit, as `statifier_router`
recommends in `docs/adr/0002-addressing.md`, the Amendment of 2026-10-02
on a durable execution's outbound BasicHTTP send.
Made inside the delivery, the POST would keep the transaction, the
execution's lock and SQLite's single write lock held for as long as the
desk took to answer, and would leave even for a step that then rolled
back. Made from the job, a slow desk holds none of them, and no POST
leaves for a step that never committed.

A desk that does not answer 2xx, or does not answer at all, is retried:
the job makes the POST up to three times. A send that still fails comes
back into the execution through `StatifierRouter.Delivery.deliver_event/4`,
the one way back in the router's record names, with `create: :never` over
the hold's address row from `StatifierRouter.Addresses.by_execution/2`: an
external `error.communication` event carrying the send's id, delivered
in a step of its own under the plan id `desk_post_failure`. The chart
takes it from `waiting` to its other final state, `desk_unreached`. A
failure that reaches no execution - the hold has finished, or its address
row is gone - is cancelled, which keeps the job and its reason in the jobs
table as the dead letter.

A send with no target, which statifier plans as an `error.communication`
raise and no request, plans no job: the executor fails it at once, and
`statifier_persistence` enters `error.communication` into the execution
in the same step. A delayed BasicHTTP send is refused the same way,
because its timer would live in the delivering process rather than in the
database.

The job's arguments carry the planned POST as it was planned, body
included, so the `reply_to` location the body hands the desk is written to
the jobs table with it, and stays there until the host prunes the job.

## The front

Expand All @@ -128,8 +160,10 @@ body of a request under `/basichttp` for the action to hand on.

## Driving it

The controller test routes a hold request, reads the `hold.placed` POST the
desk was sent, takes `reply_to` from it and POSTs
The controller test routes a hold request, drains the `desk_posts` queue
with `Oban.drain_queue/2` (the suite runs Oban with `testing: :manual`),
reads the `hold.placed` POST the desk was sent, takes `reply_to` from it
and POSTs
`_scxmleventname=copy.shelved` at that location through the endpoint:

```elixir
Expand Down
50 changes: 29 additions & 21 deletions lib/statifier_examples/hold_desk.ex
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,23 @@ defmodule StatifierExamples.HoldDesk do
processor's type strings, which is what lets the chart's `<send>` pass
the engine's type check and read `_ioprocessors['basichttp']`. A
durable execution has no session to perform the send, so the effect
reaches `execute/2`, which plans it with the router's processor and
performs what it planned, through the configuration's transport. That
runs inside the delivery's transaction: one POST to the desk, made
before the step commits. A POST the desk does not answer with a 2xx is
a failed send, which `statifier_persistence` enters into the execution
as `error.communication`, and the chart ends the hold unreached. A
send with no target, which statifier plans as an `error.communication`
raise and no request, fails without a POST and enters the execution the
same way. A delayed BasicHTTP send, whose timer would live in this
process rather than in the database, is refused, and enters the
execution the same way.
reaches `execute/2`, which plans it with the router's processor. That
runs inside the delivery's transaction, so the POST it planned is not
made there: it is handed to a `StatifierExamples.HoldDesk.DeskPost` job,
inserted in the same transaction and performed after the delivery
commits, through the configuration's transport. A desk that is slow to
answer then holds no transaction and no SQLite write lock, and no POST
leaves for a step that rolled back, as `statifier_router` recommends
(its ADR-0002, the Amendment on the outbound BasicHTTP send). A POST
the desk does not answer with a 2xx is retried, and a send that still
fails comes back into the execution as `error.communication`, delivered
by the job, and the chart ends the hold unreached. A send with no
target, which statifier plans as an `error.communication` raise and no
request, plans no job: it fails at once, and `statifier_persistence`
enters `error.communication` into the execution in the same step. A
delayed BasicHTTP send, whose timer would live in this process rather
than in the database, is refused, and enters the execution the same
way.
"""

@behaviour StatifierRouter.Resolver
Expand All @@ -49,6 +55,7 @@ defmodule StatifierExamples.HoldDesk do
alias Statifier.Machine
alias Statifier.Send.Event, as: SendEvent
alias StatifierExamples.FirstWorkflow
alias StatifierExamples.HoldDesk.DeskPost
alias StatifierExamples.RoutedWorkflow.Stepper
alias StatifierPersistence.Storage
alias StatifierRouter.{BasicHTTP, Config}
Expand Down Expand Up @@ -163,9 +170,10 @@ defmodule StatifierExamples.HoldDesk do

@doc """
The executor every create and step hands its effects to. A BasicHTTP
`<send>` is planned with `StatifierRouter.BasicHTTP.deliver/3` and each
instruction it plans is performed with the processor's `perform/2`; a
send with no target, which statifier plans as an `error.communication`
`<send>` is planned with `StatifierRouter.BasicHTTP.deliver/3`, and each
POST it plans is handed to a `StatifierExamples.HoldDesk.DeskPost` job,
inserted in the delivery's transaction and performed after it commits;
a send with no target, which statifier plans as an `error.communication`
raise and no request, fails as `{:basichttp_send_without_target,
send_id}`, and a delayed one is refused as `{:delayed_basichttp_send,
send_id}`. Every other effect is passed.
Expand All @@ -177,7 +185,7 @@ defmodule StatifierExamples.HoldDesk do
ctx = %{session_id: execution_id, opts: basichttp()}
event = SendEvent.build(send, execution_id)
{:ok, instructions} = BasicHTTP.deliver(send, event, ctx)
perform(instructions, ctx, send)
enqueue(instructions, ctx, send)
end

def execute({:send_delayed, %SendDelayed{type: type} = send}, _context)
Expand All @@ -186,13 +194,13 @@ defmodule StatifierExamples.HoldDesk do

def execute(_effect, _context), do: :ok

@spec perform([term()], map(), Send.t()) :: :ok | {:error, term()}
defp perform(instructions, ctx, send) do
@spec enqueue([term()], map(), Send.t()) :: :ok | {:error, term()}
defp enqueue(instructions, ctx, send) do
Enum.reduce_while(instructions, :ok, fn
{:handler, module, payload}, :ok ->
case module.perform(payload, ctx) do
:ok -> {:cont, :ok}
{:error, _reason} = error -> {:halt, error}
{:handler, _module, payload}, :ok ->
case payload |> DeskPost.new(ctx, send) |> Oban.insert() do
{:ok, _job} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end

# Statifier plans a send with no target as a raise of
Expand Down
157 changes: 157 additions & 0 deletions lib/statifier_examples/hold_desk/desk_post.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
defmodule StatifierExamples.HoldDesk.DeskPost do
@moduledoc """
The job that tells a branch desk a hold was placed: one BasicHTTP POST,
made after the delivery that planned it has committed.

`StatifierExamples.HoldDesk.execute/2` plans the hold's `<send
type="basichttp">` with `StatifierRouter.BasicHTTP.deliver/3` and
inserts one of these jobs for the POST it planned, through `new/3`. The
executor runs inside the delivery's transaction, and this app's Oban
writes through the same repo, so the job commits with the step that
sent and a delivery that rolls back takes the job with it. The job
carries the planned instruction and the plan context as they were
planned, written out with `:erlang.term_to_binary/1`, since the
instruction holds a struct and a module that job arguments cannot.

The job is unique on `key`, the send's dedup key written out: a
redriven step re-emits the same send with the same fields and inserts
nothing new.

`perform/1` makes the POST with `StatifierRouter.BasicHTTP.perform/2`.
It runs outside every delivery, so a slow desk holds no transaction, no
execution lock and no SQLite write lock while it answers. A POST the
desk does not take is retried; once the third POST has failed too, the
job delivers `error.communication`, carrying the send's id, back into
the hold through `StatifierRouter.Delivery.deliver_event/4`, over the
hold's address row, with `create: :never`. That is the only way back in
`statifier_router` sanctions for a send performed after the commit (its
ADR-0002, the Amendment on the outbound BasicHTTP send). A delivery
that does not settle is retried on the attempts left, without posting
again; a failure that reaches no execution - a hold already finished, or an
address row already reaped - is cancelled, which keeps the job and its
reason in the jobs table as the dead letter.
"""

use Oban.Worker,
queue: :desk_posts,
max_attempts: 5,
unique: [keys: [:key], period: :infinity]

# The attempts that POST; the ones after them only deliver the failure.
@post_attempts 3

alias Statifier.Effect.Send
alias StatifierExamples.HoldDesk
alias StatifierRouter.{Addresses, BasicHTTP, Delivery}

# The plan the failure is delivered under. The id is this app's own and
# is neither `execution`, `basichttp` nor a binding's, so the ledger and
# dedupe rows it writes are told apart from theirs; the horizon is the
# router's default dedupe horizon, 72 hours.
@failure_plan %{
id: "desk_post_failure",
create: :never,
dedupe: %{by: :message_id, horizon_ms: 259_200_000}
}

@doc """
The job for one planned instruction's `payload`, sent by `send` from the
execution `ctx.session_id` names.
"""
@spec new(term(), map(), Send.t()) :: Oban.Job.changeset()
def new(payload, %{session_id: execution_id} = ctx, %Send{} = send) do
new(%{
"execution_id" => execution_id,
"send_id" => send.send_id,
"key" => key(execution_id, send),
"instruction" => Base.encode64(:erlang.term_to_binary({payload, ctx}))
})
end

@impl Oban.Worker
def perform(%Oban.Job{args: args, attempt: attempt}) when attempt <= @post_attempts do
{payload, ctx} = decode(args["instruction"])

case BasicHTTP.perform(payload, ctx) do
:ok -> :ok
{:error, reason} when attempt < @post_attempts -> {:error, reason}
{:error, reason} -> failed(args, reason)
end
end

# Every POST attempt failed and the failure's delivery did not settle:
# deliver it again, without posting again.
def perform(%Oban.Job{args: args}), do: failed(args, :desk_post_attempts_spent)

# Read back without :safe, as the router README's recipe reads its own:
# :safe refuses an atom this node has not created yet, and a job queued
# before a restart can name one (a send's owner kind, a struct field)
# that no module has loaded since. The jobs table is written only
# through this app's repo, by `new/3`.
@spec decode(String.t()) :: {term(), map()}
defp decode(instruction), do: instruction |> Base.decode64!() |> :erlang.binary_to_term()

# The POST has failed for good: tell the hold, or keep a dead letter.
@spec failed(map(), term()) :: :ok | {:error, term()} | {:cancel, term()}
defp failed(%{"execution_id" => execution_id} = args, reason) do
config = HoldDesk.config()

case Addresses.by_execution(config, execution_id) do
nil ->
{:cancel, {:desk_unreached_without_execution, reason}}

row ->
event =
Statifier.Event.external("error.communication",
sendid: args["send_id"],
data: %{"reason" => inspect(reason)}
)

config
|> Delivery.deliver_event(Map.put(@failure_plan, :document, row.document), row.key, %{
event: event,
message_id: args["key"],
scope: row.scope,
now: DateTime.utc_now()
})
|> settled(reason)
end
end

@spec settled(StatifierRouter.outcome() | {:error, term()}, term()) ::
:ok | {:error, term()} | {:cancel, term()}
defp settled({:delivered, _plan, _execution_id}, _reason), do: :ok
defp settled({:duplicate, _plan}, _reason), do: :ok
defp settled({:dropped, _plan, why}, reason), do: {:cancel, {:desk_unreached, why, reason}}
defp settled({:error, _reason} = error, _reason_sent), do: error

# The send's dedup key, the fields the `scxml-send-key` header carries,
# each nil written as "-".
@spec key(String.t(), Send.t()) :: String.t()
defp key(execution_id, send) do
Enum.map_join(
[
execution_id,
send.send_id,
send.macrostep,
send.microstep,
send.round,
send.c_index,
owner(send.owner),
send.ordinal
],
"/",
&field/1
)
end

@spec field(String.t() | non_neg_integer() | nil) :: String.t()
defp field(nil), do: "-"
defp field(value) when is_integer(value), do: Integer.to_string(value)
defp field(value), do: URI.encode_www_form(value)

@spec owner(Send.owner() | nil) :: String.t() | nil
defp owner({kind, state, block}), do: "#{kind}.#{state}.#{block}"
defp owner({:transition, transition}), do: "transition.#{transition}"
defp owner(nil), do: nil
end
Loading
Loading