YmerNode.Scripts.Runner (Ymer Node v0.2.1)

Copy Markdown View Source

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:

reasonwhat it means
:not_acceptedthe row's accepted hash is not its code hash
:not_loadedthis node has no compiled module for the script
:unknown_actionthe script serves no action by that name
:invalid_schemathe action's own :properties are not a schema jsv can build
:invalid_argsthe arguments do not satisfy that schema
:script_errorthe script answered {:error, term}
:script_raisedthe script raised; the detail carries its own line
:script_exitedthe run process exited for some other reason
:bad_returnrun/3 answered neither {:ok, _} nor {:error, _}
:result_not_encodablethe value does not encode to JSON
:timeoutthe 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

deadline(schema)

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

in_flight?(name)

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.

run(script, action, args)

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.