Skip to content

Adds an asynchronous invoke handler on Oban - #43

Merged
johnnyt merged 2 commits into
mainfrom
se-d74-async-invoke-handler
Sep 1, 2026
Merged

Adds an asynchronous invoke handler on Oban#43
johnnyt merged 2 commits into
mainfrom
se-d74-async-invoke-handler

Conversation

@johnnyt

@johnnyt johnnyt commented Sep 1, 2026

Copy link
Copy Markdown
Member

Every call in this app was answered inside the step that made it. cancel/2
planned nothing, the timeout fields were decorative, and no invocation
outlived a step - which left the most production-shaped part of the invoke
contract unexercised by the reference embedder.

It has one asynchronous call now.

The beat

The signup wizard's company-details step. Collecting a company's details
is a human step that takes hours, and the chart already surrounds it with the
clock that makes waiting safe: the onboarding group arms signup.abandoned
at the head of its own body and listens for it on its interrupt rail. So a
run resting on that call has a live invocation and an armed deadline, which
is the whole shape this bead was filed for. The step also leaves its state on
the answer, which is the condition statifier_persistence's ADR-0007 names
in its Consequences for idempotent re-entry.

Which calls a deployment defers is a host's decision, not a fact about
the document: :pending is StatifierPersistence.Driver's arm and a
Statifier.Session has no such thing, so a live session run of the same
chart answers the same call inline and the chart cannot tell the difference.
This app makes that decision in one predicate, AsyncCalls.async?/2, read
per block off the <param> the step emits.

What it does

  • StatifierExamples.Charts.AsyncCalls - the policy, the
    StatifierOban.Invoke.Handler behaviour implementation (config/0 and
    run/1), and the enqueue and cancel doors, called from the durable
    driver's executor because that is where the whole %Effect.Invoke{} is in
    reach and JobArgs needs it.
  • StatifierExamples.Charts.AsyncCalls.Delivery - the
    StatifierOban.Invoke.Delivery seam. The package's default answers
    run-liveness from Statifier.Session's registry; this app has no session
    process, so the answer is the stored run and the persisted position.
  • Durable gained the :pending dispatch arm and two cold entry points,
    complete_invocation/3 and fail_invocation/3, shaped exactly like the
    existing deliver/2.
  • Invoke jobs get their own Oban queue. The two job kinds fail differently -
    a timer delivers an event and is over in milliseconds, an invoke job runs
    the host's actual work - and sharing one queue would let a backlog of slow
    calls delay every reminder behind it.

Durable.answer/3 deliberately carries no :active pre-check of its
own, unlike deliver/2. The driver reads liveness off the loaded position
inside the run's serialization strategy, so a cancel cannot land between the
read and the step; a pre-check here would answer the same question earlier
and worse. Sabotage confirmed it: with the pre-check present, the
driver-discard path was unreachable.

Evidence

11 new tests in test/statifier_examples/charts/async_calls_test.exs, each
sabotaged red by hand and reverted from a backup (13 mutations run in all,
including the pin guard and the feed-ordering test the change moved):

  • the run rests with an :active record, the invocation live in a
    cold-decoded persisted position, and the work in a stored job;
  • the job answers from a process that has never seen the run - rebuilding
    chart, position and run out of SQLite - and the run completes and
    provisions;
  • a second answer for the same invocation is discarded;
  • the abandonment deadline cancels the stored job and routes the chart's own
    abandon outcome;
  • a completion arriving after that deadline is discarded;
  • the failing door routes error.communication.invoke.<id> to the step's
    error outcome.

Five existing tests moved because the shipped wizard now genuinely rests
mid-call, and docs/demo-script.md section 8 was rewritten around the two
readings the beat now produces. Full gate green: 239 tests, 76.9% coverage,
dialyzer and credo clean.

The dependency pin

statifier_persistence moves from Hex ~> 0.2 to an INTERIM git pin on
65ef280d77b70c7560fb045ae71e1ec3bc08709d, one mix.lock line, no
override: needed. 0.2.0 carries the driver but not ADR-0007's seam - the
:pending arm and the done_invocation/5 / failed_invocation/5 doors this
change consumes.

FINAL re-pin after the operator publishes statifier_persistence 0.3.0
({:statifier_persistence, "~> 0.3"}, retiring @statifier_persistence_ref
and its guard). Tracked by se-l9s.

test/statifier_examples/dependency_pins_test.exs - a single-test file
guarding the same dependency on its own - is folded into
test/statifier_examples/mix_deps_test.exs, where the other pin guards live,
rather than left to contradict it.

A finding for upstream

StatifierOban.Invoke.Handler's run/1 is handed the effect and no
scope
. The scope is on the job row and the worker reads it to deliver with,
but the work never sees it - so a handler whose work keys on the run cannot
be written against the base as shipped. That is why myapp:provision, this
app's one call that writes and keys on the run id, stays synchronous, and why
the asynchronous example is a call whose work is run-independent. Recorded in
AsyncCalls' moduledoc and queued for the operator; nothing in
statifier_oban is touched here.

Provenance

  • Bead: se-d74, implementing campaign-024 ruling R-c (the seam as
    granted).
  • Upstream: statifier_persistence PR 35 (sp-e50), the ADR-0007 seam this
    consumes; the pin names that repo's main tip.
  • No sibling PR was rebased over: main had not moved from 2598cd83.
  • Three file-map widenings were granted by the campaign conductor before the
    commit and are the reason config/config.exs, docs/demo-script.md and
    test/statifier_examples_web/live/editor_live_test.exs appear here.

The wizard's company-details step is this app's one asynchronous
call now: the durable driver answers `:pending`, the work runs as
an Oban job on statifier_oban's invoke-handler base, and the
answer re-enters the stored run through the driver's completion
door.

A run therefore rests durably mid-invocation, survives a restart,
and completes when the job answers. The onboarding group's
abandonment deadline cancels the stored job and routes the
chart's own abandon outcome, and a completion arriving after it
is discarded.

Pins statifier_persistence to a git ref for the ADR-0007 seam
until 0.3.0 is published, and gives invoke jobs their own Oban
queue.

Refs: se-d74
Every test reaches the queue through `AsyncCalls.queue/0`, so a
config naming it differently - or not at all - would strand every
invocation in the running app while the suite stayed green. The
deployment's queue list and the module's own name are now
asserted against each other, as mix.exs and mix.lock are.

Found by the unattended verify pass on se-d74.

Refs: se-d74
@johnnyt
johnnyt merged commit 5bafc2f into main Sep 1, 2026
1 check passed
@johnnyt
johnnyt deleted the se-d74-async-invoke-handler branch September 1, 2026 02:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant