-
-
Notifications
You must be signed in to change notification settings - Fork 1
603. Caveats
Known trade-offs, written down rather than left to be discovered. None of these is a bug in the sense of "will be fixed" — each is a consequence of a design decision F-Mesh makes on purpose, and each has a shape you can avoid once you know it exists.
A component that writes to an output port and then decides to wait keeps what it wrote. The signals stay on the output port, and when the component finally activates successfully they are flushed downstream together with the new ones.
component.WithActivationFunc(func(ctx context.Context, this *component.Component) error {
// an audit record, written first — perfectly reasonable-looking
if err := this.OutputByName("log").PutSignals(signal.New("attempt")); err != nil {
return err
}
if !this.Inputs().AllHaveSignals() {
return component.ErrWaitKeepingInputs
}
return this.OutputByName("out").PutSignals(signal.New("done"))
})If this component waits once and then succeeds, downstream receives two "attempt" records.
Why it is this way. Waiting is decided by your code, at any point, on any condition — which is what makes it flexible enough to express "wait until I have ten of these" or "wait unless the header says otherwise". The runtime cannot know in advance that an activation will end in a wait, so it cannot refuse the writes that came before.
Avoid it by deciding before you write. Check readiness at the top of the activation function and write outputs only on the path that will not wait:
component.WithActivationFunc(func(ctx context.Context, this *component.Component) error {
if !this.Inputs().AllHaveSignals() {
return component.ErrWaitKeepingInputs // decide first
}
if err := this.OutputByName("log").PutSignals(signal.New("attempt")); err != nil {
return err
}
return this.OutputByName("out").PutSignals(signal.New("done"))
})component.RequireInputs("a", "b") composed via component.Sequential does exactly this, and is
worth using when the condition is simply "these ports must all have signals".
Fan-out hands the same *Signal pointer to every destination, and those destinations activate
concurrently. Nothing copies the payload, so two components mutating a received map are racing on
the same map.
// src fans out to w1 and w2
component.WithActivationFunc(func(ctx context.Context, this *component.Component) error {
m, _ := signal.As[map[string]int](this.InputByName("in").Signals().First())
m["n"]++ // DATA RACE: w1 and w2 hold the same map
return this.OutputByName("out").PutSignals(signal.New(m))
})Why it is this way. Copying every payload on every pipe would make fan-out cost proportional
to the data rather than the graph, and F-Mesh cannot copy a payload it knows nothing about — any
has no Clone().
The contract: treat everything you receive as read-only. Read what arrived, produce new signals from it, and never mutate a received map, slice or pointer.
m, err := signal.As[map[string]int](this.InputByName("in").Signals().First())
if err != nil {
return err
}
next := maps.Clone(m) // your copy, your rules
next["n"]++
return this.OutputByName("out").PutSignals(signal.New(next))Run your mesh tests with -race. This is the one caveat a tool will catch for you, reliably
and immediately — make race in this repo, go test -race ./... in yours.
Note that a payload used by exactly one destination is safe in practice. Mutating it is still bad practice, because adding a second pipe later turns a working mesh into a racy one with no other code change.
Under the default StopOnFirstErrorOrPanic strategy, the mesh checks whether it must stop before
it drains. A cycle in which one component fails and three succeed loses the output of all three:
their signals were written to their output ports, and those ports are never flushed.
_, err := fm.Run(ctx)
// err names the failing component; the other components' results
// sit unread on their output portsWhy it is this way. The alternative — drain, then stop — pushes a half-finished cycle's data downstream, so consumers see partial input from a cycle that failed. Which of the two is worse depends on the mesh.
Work around it by choosing a different strategy when partial progress is more valuable than a clean stop:
fmesh.New("mesh", fmesh.WithErrorHandlingStrategy(fmesh.IgnoreAll))…and handling failures yourself via RuntimeInfo, an OnError component hook, or an error output
port that feeds a dedicated handler component.
This one is tracked as an open issue rather than settled design; a WithDrainOnError option would
make it a choice instead of a default.
RuntimeInfo.Cycles retains every cycle of a run, with every activation result, and the
default retention is unlimited. A long run keeps all of it:
fm, _ := fmesh.New("mesh", fmesh.WithUnlimitedCycles()) // 0 cycle limit
// ...50,000 cycles later, 50,000 cycle records are still in memoryThe default CyclesLimit of 1000 bounds this for you. If you remove that limit, put a retention
limit back:
fmesh.New("mesh",
fmesh.WithUnlimitedCycles(),
fmesh.WithCyclesHistoryLimit(100), // keep a sliding window of the last 100
)There is no backpressure either: signals live in memory for the whole run, so a mesh is sized by what it is asked to process rather than by a buffer.
InputByName / OutputByName return nil when no port has that name, so a typo dereferences nil:
this.InputByName("inn").Signals() // panic: nil pointer dereferenceInside an activation function the panic is recovered and reported as a Panicked activation
result, so it does not take the process down — but the message is a nil dereference rather than
"no such port". Outside one (setup code) it panics outright.
Port names are strings and are not validated at wiring time. Keep them in constants if that worries you:
const portIn = "in"
component.WithInputs(portIn)
this.InputByName(portIn)Two behaviours that look like bugs and are not:
-
A mesh with no seeded inputs does nothing and returns
nil. Components activate only when an input port has signals; there are no source components. Seed an input to start the mesh. See 401. Scheduling rules. -
Hitting the cycle or time limit returns an error.
ErrReachedMaxAllowedCyclesandErrTimeLimitExceededmean the mesh was stopped, not that it failed. Useerrors.Isto tell them apart from a real failure.