-
-
Notifications
You must be signed in to change notification settings - Fork 1
502. Plugins
A plugin is a named bundle of initialization. You pass it to a constructor, its Init runs once, and whatever it set up — hooks, ports, metadata, pipes — is simply part of the thing you constructed.
That is all a plugin is. Its value is that it makes a concern portable: written once, it can be attached to any component or any mesh without editing either.
Plugins come at two levels, with the same shape:
| Level | Interface | Attach with |
Init receives |
Query |
|---|---|---|---|---|
| Component | component.Plugin |
component.WithPlugins(...) |
*component.Component |
c.PluginRegistered(name) |
| Mesh | fmesh.Plugin |
fmesh.WithPlugins(...) |
*fmesh.FMesh |
fm.PluginRegistered(name) |
Both interfaces are two methods:
GetName() string
Init(T) errorRules that hold at both levels:
- Names are unique. Attaching two plugins with the same name is a construction error.
-
A failing
Initfails construction.Newreturnsniland the error; you never get a half-initialized object. -
Initruns in sorted name order. Plugins mostly register hooks, and hooks fire in registration order — so init order is visible in behavior, and sorting keeps it identical between runs.
Init runs during component.New, after every other option has been applied. The component is fully formed at that point, so a plugin can add ports, metadata, state and hooks:
type PricePlugin struct{}
func (PricePlugin) GetName() string { return "price" }
func (PricePlugin) Init(c *component.Component) error {
// Extend the component's interface
if err := c.AddInputs("price_in"); err != nil {
return err
}
if err := c.AddOutputs("price_out"); err != nil {
return err
}
// Extend its behavior
c.SetupHooks(func(hooks *component.Hooks) {
hooks.OnCreation(func(this *component.Component) error {
this.State().Set("base_price", 77.5)
return nil
})
hooks.OnActivation(func(this *component.Component) error {
return this.OutputByName("price_out").PutPayloads(999.0)
})
})
// Extend its metadata
c.AddLabel("plugin/price/version", "v1.2.4")
return nil
}c, err := component.New("priced",
component.WithInputs("i1"),
component.WithOutputs("o1"),
component.WithActivationFunc(myLogic),
component.WithPlugins(PricePlugin{}),
)Order inside component.New: options → plugin Inits → OnCreation hooks. A plugin registering an OnCreation hook still has it run, because OnCreation fires after all plugins are initialized.
A mesh plugin bundles initialization for a whole mesh, which makes it the right home for anything cross-cutting — measuring, tracing, exporting, asserting, wiring. None of those belong to a single component, and all of them would otherwise have to be repeated in every one.
Init runs during fmesh.New, after the options, so the plugin sees a fully configured mesh.
A mesh is constructed empty and filled by AddComponents afterwards. At Init time there is nothing to walk:
func (p *MyPlugin) Init(fm *fmesh.FMesh) error {
fm.Components().ForEach(...) // always empty — the mesh has no components yet
return nil
}Instead, register an OnComponentAdded hook and instrument each component as it arrives:
type ActivationCounter struct {
count atomic.Int64
}
func (p *ActivationCounter) GetName() string { return "activation-counter" }
func (p *ActivationCounter) Init(fm *fmesh.FMesh) error {
fm.SetupHooks(func(hooks *fmesh.Hooks) {
hooks.OnComponentAdded(func(ctx *fmesh.ComponentAddedContext) error {
ctx.Component.SetupHooks(func(ch *component.Hooks) {
ch.AfterActivation(func(*component.ActivationContext) error {
p.count.Add(1)
return nil
})
})
return nil
})
})
return nil
}
func (p *ActivationCounter) Count() int64 { return p.count.Load() }counter := &ActivationCounter{}
fm, err := fmesh.New("mesh", fmesh.WithPlugins(counter))
// ... AddComponents, Run ...
fmt.Println(counter.Count())That indirection is the whole trick: the plugin observes every activation in the mesh, and no component knows it exists.
-
Components are inspected only when added. Ports created later with
AddInputs/AddOutputsare invisible to the plugin. Declare ports incomponent.New. - Component hooks run concurrently. Components in a cycle activate in parallel, so a plugin holding shared state must guard it — and if it is timing anything, take the clock readings outside its own lock, or each component gets charged for the contention caused by the others.
The plugin package ships ready-made mesh plugins.
import "github.com/hovsep/fmesh/plugin"Profiler times whole runs, single cycles, and every component's activations. The per-component numbers are the reason it exists: a Go CPU profile of a mesh is dominated by the scheduler and barely distinguishes one component from another, because every component's work is the same handful of runtime calls. Timing activations directly names the slow one.
prof := plugin.NewProfiler()
fm, err := fmesh.New("mesh", fmesh.WithPlugins(prof))
// ... AddComponents, Run ...
fmt.Println(prof.Report())runs: 1, total 2.41ms, avg 2.41ms
cycles: 7, total 2.28ms, avg 325µs
component count total avg min max
tokenizer 7 1.42ms 202µs 180µs 310µs
counter 7 612µs 87µs 71µs 140µs
| Method | Returns |
|---|---|
Runs() |
Stat for whole runs |
Cycles() |
Stat for individual cycles |
Components() |
[]ComponentStat, slowest total first |
TopN(n) |
the n busiest components, by activation count |
Report() |
the table above, as a string |
Reset() |
discards everything measured so far |
Stat carries Count, Total, Min, Max and an Avg() method. Stats accumulate across runs, which is what you want when comparing a mesh against itself; call Reset() between runs that should not be pooled. One Profiler belongs to one mesh.
TopN and Components() answer different questions. Components() sorts by total time — what is slow. TopN sorts by activation count — what is hot. A component that activates every cycle and does almost nothing can matter more than one that is individually slow but rarely runs.
A mesh of any size accumulates long stretches of wiring that say nothing a reader could not have guessed: everything that keeps time gets the clock, everything that wants the weather gets the weather. Written out, that is a list to maintain, and forgetting it fails silently — the component sits there, looking perfectly connected, waiting on inputs that never arrive.
Autowire makes the list derivable. Give it a rule mapping an output port to the input port name that should receive it, and it wires every match:
fm, err := fmesh.New("mesh",
fmesh.WithPlugins(plugin.AutowireBroadcast("time")),
)
// No PipeTo calls: every component with an input named "time"
// is wired to every output named "time".
err = fm.AddComponents(clock, heart, lung)Three ready-made conventions plus the general form:
| Constructor | Rule |
|---|---|
plugin.AutowireBroadcast("time") |
every output named time → every input named time
|
plugin.AutowireBroadcastAs("tick", "time") |
every output named tick → every input named time
|
plugin.AutowirePrefixed("env_") |
an output → the input named <prefix><component>_<port>, e.g. env_sun_uvi
|
&plugin.Autowire{Name: func(source *component.Component, output *port.Port) string {...}} |
your own rule; return "" to decline |
Wiring happens on every arrival, in both directions: a new component is offered every output already in the mesh, and every output it brings is offered to the components already there. So the order of AddComponents does not matter — which is the property that makes a convention safe to lean on.
A mesh often wants more than one convention at once — a clock reaching everything that keeps time, and a set of environmental factors reaching whatever asked for them by name. Those are separate rules, so they are separate plugins:
fm, err := fmesh.New("habitat", fmesh.WithPlugins(
plugin.AutowireBroadcastAs("tick", "time"),
plugin.AutowirePrefixed("env_"),
))Each carries its own PluginName, so they coexist. Two conventions that happen to agree on the same output/input pair wire it once, not twice.
Two limits worth knowing:
- A component is never wired to itself. A convention is not how you express a loopback — use
c.LoopbackPipe(out, in). - Ports must exist before
AddComponents, since arrival is the only moment a component is looked at.
| You want to... | Use |
|---|---|
| Give one kind of component extra ports, state or behavior | Component plugin |
| Observe or wire every component, without touching any of them | Mesh plugin + OnComponentAdded
|
| React to a lifecycle event in one specific mesh, once | Just a hook — see 501. Hooks |
A plugin is worth the wrapper when the setup is reusable or belongs to a concern rather than to a component. For a one-off, SetupHooks directly is simpler and says more.
For complete API documentation, see FMesh, Component and the plugin package.