User guide
This page describes the execution model in detail. It assumes you have read the quick start example on the home page.
Anatomy of a node
A node has two halves: a declaration that never changes and a state that the engine mutates.
| Part | Type | Contents |
|---|---|---|
spec | NodeSpec | ports and settings keys - the contract |
properties | NodeProperties | id, status, editor position |
settings | Dict{Symbol,Any} | configuration values |
data | Dict{Symbol,Any} | cached outputs, keyed by output port label |
state | NodeState | ready ports, run id, per-run log |
input_buffer | nested Dict | input_buffer[port][connection id] = value |
The spec is the single source of truth. setdata! refuses labels that are not output ports, getinputdata refuses labels that are not input ports, and connection validation refuses ports that do not exist at all.
Ports are declared with PortSpec:
PortSpec("Input table", DataFrame, :table) # required SinglePort
PortSpec("Extra", Int, :extra, SinglePort(); required = false) # optional
PortSpec("Values", Int, :values, MultiPort()) # many-to-oneThe datatype is used for connection type checking, label is the symbol you use everywhere in code, and required controls whether the engine refuses to run a node whose buffer is still empty.
Introspection helpers: haveinputs, getportnumber, getporttype, getportspec, isportexist, ismultiport, nodetypestr.
Building a graph
w = Workflow(1) # Workflow{DAW}
id = add_node!(w, DataNode(MyNode, spec))
cid = add_connection!(w, parent_id, :out, child_id, :in)add_connection! validates through check_connection_validity and raises unless:
- both nodes exist,
- both ports exist in their specs,
- the target input port is free, or declared as a
MultiPort, - the output datatype is a subtype of the input datatype.
A rejected connection consumes nothing. Identifiers come from monotonically increasing counters, so ids of deleted nodes and connections are never reused - after delete_node!(w, 2) the next node is 3.
Two ordering rules are worth remembering:
add_node!resets a node not in:idleor:cleanstate.
Configure with setsettings! after adding.
- if the parent is already
:cleanwhen the connection is created, its output is copied into the child buffer immediately - no recomputation needed.
Topology queries: getnode, getconnection, isnodeexist, find_connections, getportconnections, get_parents, get_children.
Writing node behaviour
Behaviour is attached to a singleton AbstractNodeType subtype through execute_unsafe!:
struct Doubler <: AbstractNodeType end
function MetidaFlows.execute_unsafe!(node::DataNode{Doubler})
x = getinputdata(node, :in)
setdata!(node, :out, 2x)
return [:out] # ports produced by this call
endThe returned vector is the contract with the engine: only connections leaving these ports are refreshed by push_buffer!, and the vector is stored as the ready_ports state. A node may legitimately return a subset of its output ports.
Four optional hooks specialise validation; all default to true:
| Hook | When | Status on failure |
|---|---|---|
validate_node | before execution, from execution_node_validation | :invalid_node |
validate_settings | before execution | :invalid_settings |
validate_result | after execution, before propagation | :invalid_result |
setsettings_unsafe! | on settings assignment | - (customisation point) |
Because validate_result runs before push_buffer!, a node that fails result validation never publishes anything downstream.
Statuses and the execution lifecycle
| Status | Meaning |
|---|---|
:idle | freshly created, never executed, no settings applied |
:dirty | needs (re)computation |
:executing | currently running - also used to detect re-entry |
:clean | up to date, outputs cached |
:failed | execute_unsafe! raised |
:invalid_node | structure or required inputs missing |
:invalid_settings | validate_settings returned false |
:invalid_result | validate_result returned false |
execute! walks these stages:
- reset the per-run node log if the run changed;
- detect re-entry (
check_cyclic) and warn instead of recursing forever; - return immediately if the node is already
:clean; - mark
:executing; - execute parents recursively (
execute_upstream); - validate structure, required inputs (
check_input_buffer) and settings; - call
execute_unsafe!; - validate the result;
- store
ready_portsand push outputs into the child buffers; - invalidate children (
invalidate_downstream); - mark
:clean.
The four flags in parentheses come from ExecuteSettings:
execute!(w, id) # all flags on
execute!(w, id; settings = ExecuteSettings(; execute_upstream = false))
execute!(w, id; settings = ExecuteSettings(false)) # all flags offexecute! returns the vector of ports produced, or an empty vector when the node was rejected or failed - so an empty result is a signal to inspect getstatus.
Invalidation and caching
Caching is what makes incremental recomputation work: a :clean node returns its stored ready_ports without calling execute_unsafe! again. Everything that could make a cached result wrong therefore has to mark nodes :dirty.
invalidate_downstream! is the propagation primitive. Starting at a node it marks it :dirty, drops its cached outputs, deletes the child buffer entry belonging to each outgoing connection and recurses. Traversal stops at nodes that are already :dirty, since their subtree was invalidated earlier.
It is triggered by setsettings!, add_connection!, delete_connection! and by execute! itself.
Three reset levels are available:
| Function | Status | Cached outputs | Settings, buffers, logs |
|---|---|---|---|
reset_status! | :dirty | kept | kept |
mark_dirty!, reset! on a workflow | :dirty | cleared | kept |
reset! on a node | :idle | cleared | cleared |
Low-level buffer access - setinputbuffer!, invalidate_buffer!, push_buffer! - is available when you drive execution manually, but note that writing a buffer by hand bypasses invalidation.
Schedulers
scheduler!(w) # DAW
scheduler!(w; maxiter = 5000) # ABWDAW | ABW | |
|---|---|---|
| Order | topological, via makegraph | readiness queue |
| Cycles | rejected before execution | not detected up front |
| Per run | reset!: statuses and cached outputs | statuses only |
| Required inputs | enforced | not enforced (ExecuteSettings(false)) |
| Nodes executed | every node, once | only nodes reachable from input-free nodes |
Both return true when the traversal finishes. That is not a success flag: individual nodes may still end up :failed or :invalid_*, so check getstatus when correctness matters.
In ABW, a node popped while a parent is still :dirty is dropped and re-queued later by that parent; isready implements the check. A node that has input ports but no incoming connection never becomes ready and stays :dirty.
Many-to-one inputs
A MultiPort input accepts any number of connections. Values are keyed by connection id, so parents never overwrite each other:
spec = NodeSpec("Collect",
[PortSpec("values", Int, :ins, MultiPort())],
[PortSpec("sum", Int, :out)])
function MetidaFlows.execute_unsafe!(node::DataNode{Collect})
buffer = getinputdata(node, :ins) # Dict{connection id, value}
setdata!(node, :out, sum(values(buffer); init = 0))
return [:out]
endFor a SinglePort, getinputdata returns the single value, or nothing if the buffer is empty. A specific connection can always be read with getinputdata(node, label, connection_id).
Serialization and schemas
workflow_to_dict produces a JSON-ready snapshot of the structure: nodes, connections and the incoming/outgoing indices. Note that "nodes" and "connections" are keyed by stringified ids while "incoming" and "outgoing" keep integer ids, and that execution state (run_id, logs, cached data) is not included.
Building blocks: node_to_dict, node_properties_to_dict, spec_to_dict, portspec_to_dict, connection_to_dict.
Schemas describe a node to a UI. Extend them per node type:
function MetidaFlows.settings_schema_usermod!(d, node::DataNode{MyNode})
d["schema"] = Dict("threshold" => Dict(
"type" => Float64, "default" => 0.5, "required" => true,
"validator" => x -> 0 <= x <= 1))
return d
end
function MetidaFlows.node_schema_usermod!(d, node::DataNode{MyNode})
d["section"] = "Statistics"
d["color"] = "#8b5cf6"
return d
endsettings_schema and node_schema call these hooks. Keep in mind that the "settings" key of node_to_dict holds the settings schema, not the current values.
A larger example
Loading a CSV file, converting it and summarising it:
using MetidaFlows, CSV, DataFrames
using MetidaFlows: Workflow, NodeSpec, PortSpec, DataNode, AbstractNodeType,
add_node!, add_connection!, setsettings!, scheduler!,
getdata, setdata!, getinputdata
struct LoadCSV <: AbstractNodeType end
struct ToDataFrame <: AbstractNodeType end
struct Summarise <: AbstractNodeType end
csv_spec = NodeSpec("Load CSV", PortSpec[],
[PortSpec("CSV File", CSV.File, :csv)], [:file])
df_spec = NodeSpec("DataFrame",
[PortSpec("CSV File", CSV.File, :csv)],
[PortSpec("DataFrame", DataFrame, :dataframe)])
sum_spec = NodeSpec("Summary",
[PortSpec("DataFrame", DataFrame, :dataframe)],
[PortSpec("Row count", Int, :nrows)])
function MetidaFlows.execute_unsafe!(node::DataNode{LoadCSV})
setdata!(node, :csv, CSV.File(node.settings[:file]))
return [:csv]
end
function MetidaFlows.execute_unsafe!(node::DataNode{ToDataFrame})
setdata!(node, :dataframe, DataFrame(getinputdata(node, :csv)))
return [:dataframe]
end
function MetidaFlows.execute_unsafe!(node::DataNode{Summarise})
setdata!(node, :nrows, size(getinputdata(node, :dataframe), 1))
return [:nrows]
end
w = Workflow(0)
id1 = add_node!(w, DataNode(LoadCSV, csv_spec))
id2 = add_node!(w, DataNode(ToDataFrame, df_spec))
id3 = add_node!(w, DataNode(Summarise, sum_spec))
add_connection!(w, id1, :csv, id2, :csv)
add_connection!(w, id2, :dataframe, id3, :dataframe)
setsettings!(w, id1, Dict(:file => "data.csv"))
scheduler!(w)
getdata(w, id3, :nrows)Changing the file path invalidates the whole chain; executing id3 again recomputes all three nodes. Changing nothing and executing again recomputes nothing.
Things to keep in mind
- Settings are merged, not replaced:
setsettings!only overwrites the keys you pass. - Manual
execute!withexecute_upstream = falsewill not refresh stale parents; the node is simply rejected with:invalid_nodeif a required input is missing.