One run of one action — what is checked before it, the process it happens in, and the shape of every answer that comes back.
A run never happens in the caller's process. Each one is a child of
YmerNode.Scripts.TaskSupervisor, awaited with a deadline and killed when the
deadline passes. That buys isolation from a crash and from a loop, and nothing
else: it is not a sandbox. A run reaches the notebook, the network and the
filesystem with the node's own permissions, because a script this node has
accepted is a script this node trusts.
Two bounded reads, not one
Before a run can start, the node reads the script's own
YmerNode.Script.actions/0 — is there such an action, what arguments does it
take, how long may it have — and its YmerNode.Script.declarations/0, which
says what secrets the run may resolve. Both are script code, both can loop, so
neither is called in the caller's process either.
That is why a run is two tasks. The metadata read is bounded by a fixed five seconds, because the run's own deadline is a number that read has not fetched yet; the run that follows is bounded by the action's timeout. One task would mean choosing the deadline before knowing it.
The deadline
Thirty seconds unless the action asks for more, and never more than five
minutes — deadline/1 is the whole rule. The cap is the node's and not the
script's: the node itself will schedule scripts later (a fetch-and-cache pass
over the references it derives), and a run with no bound the node imposed
would hold that slot forever.
A run that passes its deadline is killed with :brutal_kill — no cleanup, no
terminate. Whatever it had already done outside this VM stands: a row
written, a request sent, a payment taken. That is the script's problem to make
safe rather than the node's to undo, which is why every action carries a write
mark and a worker is told to read before it writes.
Native code cannot be interrupted: a run past its deadline is answered
:timeout on time and its process is gone, while the native call runs to its
end on a dirty scheduler, of which the node has one per core.
The run's context carries the deadline too, as the instant it falls at, so a battery that waits — a throttle — can refuse a wait the run could not finish rather than sit in a queue until the kill.
In flight
A run registers itself in the YmerNode.Scripts.Runs registry — :duplicate,
because one script may have several runs at once — and holds that entry for
the whole of run/3, not for either task inside it. It is taken in the
calling process and released in an after, so it covers the metadata read,
the argument check that happens between the two tasks, and the run itself; the
registry drops it anyway if that process dies, so nothing leaks from a caller
that is killed.
Each task takes a second entry of its own and arms a kill one second behind
its deadline, because a caller can die mid-run — a client cancelling, an rpc
interrupted — and the task is not linked to it. Without the entry the run
would vanish from in_flight?/1 while still executing, and a write could
purge the tree under it; without the kill it would run past the cap with
nobody waiting. While the caller lives its own Task.yield answers first,
and the task drops its entry before it answers, so the backstop changes
nothing a live caller sees — the moment run/3 returns, in_flight?/1 is
false.
One entry per call rather than one per task is what makes it mean anything. A run is two tasks with the node's own work between them, and an entry that disappeared with the first task would leave a window where a run in progress reads as no run at all.
The order inside run/3 is the other half: the entry is taken before
YmerNode.Scripts.Loader is asked for the module. Nothing else hands out a
script's module, and the loader refuses a purge while this entry stands, so a
write either sees the run and is refused, or is already holding the loader and
hands the run the tree it leaves. in_flight?/1 is the same lookup, read by
YmerNode.Scripts at its own doors for an early refusal with the name in it.
The reason both exist: :code.purge/1 kills a process still executing the old
code, so landing a write over a live run would be the node breaking the same
read-before-write rule it asks scripts to keep.
Every answer
A script's {:ok, term} is answered {:ok, term} — but only after the node
has proved the term encodes to JSON. The answer's next stop is a JSON-RPC
result, and a term that cannot be encoded there would surface as the node
crashing rather than as the script being wrong. Checking here means the error
names the offending value while the node still knows which script produced it.
Everything else is {:error, {reason, detail}} — the shape
YmerNode.Scripts.Compiler already uses, reason naming the rule and detail
naming the script, the action and what happened:
| reason | what it means |
|---|---|
:not_accepted | the row's accepted hash is not its code hash |
:not_loaded | this node has no compiled module for the script |
:unknown_action | the script serves no action by that name |
:invalid_schema | the action's own :properties are not a schema jsv can build |
:invalid_args | the arguments do not satisfy that schema |
:script_error | the script answered {:error, term} |
:script_raised | the script raised; the detail carries its own line |
:script_exited | the run process exited for some other reason |
:bad_return | run/3 answered neither {:ok, _} nor {:error, _} |
:result_not_encodable | the value does not encode to JSON |
:timeout | the deadline passed and the run was killed |
:script_error carries the script's own term, with two exceptions. A run that
hands back an unresolved secret — declared and unset, or asked for and never
declared — is rendered by naming the secret and the verb that answers it,
because the node is what knows the verb and the script is not. A throttle's
refusal needs no exception of its own: it is a YmerNode.Scripts.Throttle.Error,
whose message already names the throttle, what stopped the request and what
answers it, and an exception is rendered by its message. Every other term is
inspect/1'd as it came.
Summary
Functions
The deadline one action gets: its own :timeout where it names one, thirty
seconds where it does not, and never past the five-minute cap.
Whether a run of this script is in flight right now — the question every door that replaces or removes a script's code asks before it lands.
Runs one action of one accepted script and answers what it produced.
Functions
The deadline one action gets: its own :timeout where it names one, thirty
seconds where it does not, and never past the five-minute cap.
Examples
iex> YmerNode.Scripts.Runner.deadline(%{properties: %{}, write: false})
30000
iex> YmerNode.Scripts.Runner.deadline(%{properties: %{}, write: false, timeout: 60_000})
60000
iex> YmerNode.Scripts.Runner.deadline(%{properties: %{}, write: false, timeout: 900_000})
300000
Whether a run of this script is in flight right now — the question every door that replaces or removes a script's code asks before it lands.
Runs one action of one accepted script and answers what it produced.
action arrives as a string from the wire and is matched against the names the
script's actions/0 reports — never converted with String.to_atom/1, which
would let a caller fill the atom table one unknown action at a time.