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

Copy Markdown View Source

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

all_facts()

Every script's facts, keyed by name.

boot_compile()

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.

boot_compile?()

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.

check(code)

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.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

facts(name)

What this VM knows about one script right now — nil for a script this boot has never compiled.

`%{loaded?: boolean, module: modulenil, modules: [module], contract: integernil,
description: String.t()nil, actions: map, declarations: mapnil,

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.

load(code, opts \\ [])

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.

start_link(opts)

unload(name, top_module, opts \\ [])

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.