Owns the VM's script module trees: every compile and every purge goes through this process, and it remembers what each one produced.
Why a process at all
Compiling is a mutation of a global table. Two concurrent writes of one script
would race on :code.purge/1 and could leave the tree half-unloaded; two
writes of different scripts would interleave diagnostics. A single GenServer
makes every compile a queued, whole operation.
Compiles happen at boot, at a write, and at a check. The last of those is an
interactive call, so the queue sits on a caller's path: a candidate that takes
the compiler's whole bound holds every other reader behind it for that long.
That is the price of the guarantee below, and it is bounded — the compile and
the metadata read each carry their own deadline, so a looping candidate costs
a wait and never the node.
It is also where the per-boot facts live: the modules a script's last compile defined, the warnings it carried, and the error it failed with. Those are facts about this VM and not about the row, so they belong in memory and die with the node; a column would claim they survive a restart, and they do not.
No purge lands over a live run
A write and a check both purge the tree they are about to replace, and
:code.purge/1 kills a process still executing the old code. A caller cannot
take that refusal for itself: between its own look at the registry and the
purge, a run can start. So the look is taken here, in the same message as
the purge — idle_guard: true on load/2 and unload/3, and always on
check/1.
It holds because of the order the two sides work in. A run registers itself before it asks for a module, and the only place a module comes from is this process. A run that registered before this message is seen, and the purge is refused; a run that registers after it is queued behind this message and is handed the tree this message leaves. There is no third case.
A write lands whole
The tree a message leaves has to be one a row vouches for. A write's compile
displaces the stored script's tree with the candidate's, and the rules that
can still refuse the candidate — the host and throttle rules read the
accepted rows, and the row write itself can meet a name another write just
took — need the
compiled module, so they cannot run ahead of the compile. Run in the caller
after it, they leave two gaps: a run queued behind the compile is handed the
candidate before the refusal lands, and executes code the node is in the act
of refusing; and two writes claiming one host each read the rows before the
other has stored its own, and both land. load/2's commit: closes both by
running those rules and the row write inside the message, between the
compile and the recording of the facts: what a queued reader sees is the
stored candidate or the restored old code, never the in-between, and the
second of two writes reads the first's row.
A candidate that is not kept — its compile failed, or its commit refused it —
is put back inside the same message: the stored row's code compiled again and
recorded, or the name forgotten where there is no row. The facts always say
what this VM holds when the call answers. A refused write therefore costs its
message two compiles, the candidate's and the restore's — the shape check/1
has always had.
A remove is the same operation from the other side: unload/3 takes the row
delete as its commit:, so the tree and the row go inside one message and no
write can land a fresh tree between the two.
Compile at boot, and at every write
init/1 compiles every accepted row before the supervisor moves on, which is
why this child sits after the migrator and before the HTTP listener: the node
never accepts a call it cannot serve. A row that fails to compile is kept,
its diagnostics recorded, its failure logged — and the boot continues. A node
that refused to start because one script stopped compiling would take the
notebook and the registry down with it, and the operator would have no way in
to fix the script. A row that hangs rather than fails is the same problem
wearing a worse face — the supervisor waits on init/1 with no bound of its
own — so the deadline that answers it belongs to the compile itself and lives
in YmerNode.Scripts.Compiler; a hung row arrives here as an ordinary
refusal and is kept and logged like any other.
First-use compilation is deliberately not an option: the first caller would pay for it, and a failure would surface in a tool result rather than in the log an operator reads after a restart.
What it reads for itself
Two reads are this module's own, spelled with Ecto rather than through
YmerNode.Scripts — which calls this module on every write, so routing
them through the context would make the pair mutually recursive at compile
time for a query that is the same query either way: the accepted rows' name
and code at boot, and one row's code for a restore. Everything else this
process touches in the database arrives as a write's commit: — the
context's own functions, handed over as a value rather than called by name.
Summary
Functions
Every script's facts, keyed by name.
Compiles every accepted row, recording each outcome. Answers the number loaded and the number refused.
Whether init/1 compiles the accepted rows at boot
(config :ymer_node, YmerNode.Scripts.Loader, :boot_compile).
Compiles a candidate and puts the stored code back, as one operation.
Returns a specification to start this module under a supervisor.
What this VM knows about one script right now — nil for a script this boot
has never compiled.
Compiles one script's code into the VM, replacing whatever tree it had.
Unloads one script's module tree and forgets its facts.
Functions
Every script's facts, keyed by name.
Compiles every accepted row, recording each outcome. Answers the number loaded and the number refused.
Public so a test can exercise the boot path without booting; init/1 is its
only other caller.
Whether init/1 compiles the accepted rows at boot
(config :ymer_node, YmerNode.Scripts.Loader, :boot_compile).
Defaults to enabled, and start_link/1's own :boot_compile option wins over
it, so a test that starts this process itself can turn the boot read off
without touching VM-global config.
config/test.exs disables it for the same reason
YmerNode.Notebook.VecLoadCheck is disabled there: the sandbox holds no
checked-out connection at application start, so the boot read would fail
against a perfectly healthy build. A test that wants the boot behaviour calls
boot_compile/0 itself.
Compiles a candidate and puts the stored code back, as one operation.
What check needs. Compiling Script.X unloads whatever Script.X this VM
had, so the row of that name is read here and compiled again before this
call answers, and the candidate's own facts are never recorded. Every step is
inside this one message, which is what makes the pair indivisible: no other
caller is handed the candidate's module or its metadata, and no write can land
between the read and the restore. A caller that read the row for itself and
passed the code in would restore whatever the row held when it looked — and
a write that landed meanwhile would leave the VM running code the row no
longer holds, which is the acceptance invariant broken from the inside.
Always guarded: the candidate's compile purges the running tree exactly as a write does.
Returns a specification to start this module under a supervisor.
See Supervisor.
What this VM knows about one script right now — nil for a script this boot
has never compiled.
| `%{loaded?: boolean, module: module | nil, modules: [module], contract: integer | nil, |
| description: String.t() | nil, actions: map, declarations: map | nil, |
warnings: [String.t()], error: nil | {atom, String.t()}}`.
Every key is present either way: a script that would not compile answers the
same shape with loaded?: false, no module, no metadata and its error set,
so a caller rendering a listing never has to ask which shape it got.
Compiles one script's code into the VM, replacing whatever tree it had.
Answers YmerNode.Scripts.Compiler.compile/1's own result — with commit:,
the commit's — and records the script's facts on success. A candidate that is
not kept is put back before this call answers: the stored row's code is
compiled again and recorded, success or failure, because the candidate's
compile had already purged it; where there is no row, the name is forgotten.
Nothing is ever recorded for a candidate that was not kept, and the facts say
what the VM holds when the call answers.
commit: fun runs inside this process, between the candidate's compile
and the recording of its facts: fun.(compiled) answers {:ok, value} to
keep the candidate — value is then this call's answer — or
{:error, reason} to refuse it, which restores the stored code exactly as a
failed compile does. It is how a write puts the host and throttle rules and
the row write on the far side of the compile with no gap (§ A write lands
whole): every
other compile, every restore and every facts/1 read queues behind the
whole message. A commit that raises or exits is refused as
{:error, {:commit_failed, detail}} rather than taking this process, and
with it every script's facts, down for one write.
idle_guard: true refuses with {:error, {:run_in_flight, detail}} while a
run of the script this code derives to is in flight — before anything is
purged, so that refusal alone restores nothing. Every production caller
passes it: YmerNode.Scripts' write and accept doors. The unguarded form is
the test suite's, for a fixture compiled with no row and no run behind it.
Boot never comes through here — init/1 compiles the accepted rows through
YmerNode.Scripts.Compiler directly, before any run exists.
Unloads one script's module tree and forgets its facts.
Takes idle_guard: true for the reason load/2 does: a remove purges the
tree a run may be executing inside. Takes commit: for the reason load/2
does too: a remove's row delete runs inside this message, after the purge,
so no write can land a fresh tree between the tree going and the row going —
fun.() answers {:ok, value} or {:error, reason}, and that is this
call's answer once the tree is gone and the name forgotten, whichever it
is. Without a commit the call answers :ok. top_module may be nil for a
row whose code this boot could not parse: there is no tree to purge, and the
commit still runs inside the message.