Documentation
¶
Overview ¶
Package fsm provides a small finite state machine for Go.
The machine is immutable configuration; the state itself is owned by the caller and passed to Machine.Fire as a pointer. That split is deliberate: it lets a state live inside a struct that is serialized (a protobuf field, a snapshot, a workflow variable) and replayed, without the machine holding a second, divergent copy.
Events carry typed payloads. An Event declared as Event[time.Time] can only be fired with a time.Time, and its action only ever sees a time.Time — there is no ...any in the public API, and no type assertions in user code.
Nothing in this package panics at fire time. Configuration mistakes are reported by New; unknown transitions are reported by Machine.Fire as errors. This matters when a machine is driven by a replicated log: a panic on a malformed event would take down every replica replaying it, not just one.
Groups ¶
States are flat. A Group gives a set of them shared transitions — a transition declared with FromGroup applies to every member that does not declare that event itself — but it is a declaration-time grouping that expands to ordinary rows in the transition table before New returns. Machine.Fire neither knows about groups nor pays for them.
That expansion is most of what a hierarchical state machine's substates are used for, and deliberately not all of it. A group has no entry or exit hooks, because the reason to want them is the one thing expansion cannot reproduce: in a real hierarchy, moving between two substates of the same superstate does not run the superstate's hooks, and there is nowhere to record that when every row is flat. So there is also no group Gauge — it would decrement and increment on a move that a hierarchy would treat as staying put. Count the member states individually and sum them where the counters are read.
Groups do not nest, and entering a group does not select an initial member.
Example (Participant) ¶
package main
import (
"context"
"fmt"
"github.com/floatdrop/fsm"
)
type pcpState int32
const (
pcpConnected pcpState = iota
pcpReconnecting
pcpDeleted
)
func (s pcpState) String() string {
return [...]string{"connected", "reconnecting", "deleted"}[s]
}
// A disconnect carries why it happened, so the reason cannot be lost between
// the caller and the action that logs it.
type disconnect struct {
intentional bool
reason string
}
var (
evPcpDrop = fsm.Define[disconnect]("disconnect")
evPcpReconnect = fsm.Signal("reconnect")
evPcpKick = fsm.Define[disconnect]("kick")
)
func unintentional(_ context.Context, d disconnect) error {
if d.intentional {
return fmt.Errorf("intentional disconnect (%s) must be a kick, not a drop", d.reason)
}
return nil
}
// A participant is kicked the same way whether it is connected or
// reconnecting. Naming the two as a group says that once: a third live state
// inherits the kick instead of needing the rule copied, and the group is still
// the place to look for what "live" means.
var pcpLive = fsm.NewGroup("live", pcpConnected, pcpReconnecting)
// Modelling a participant makes deletion an explicit terminal state rather
// than "absent from the map", so the last transition is expressible and can
// carry a reason.
var participantFSM = fsm.MustNew("participant",
fsm.From(pcpConnected).On(evPcpDrop).To(pcpReconnecting).
Guard("disconnect was not intentional", unintentional),
fsm.From(pcpReconnecting).On(evPcpReconnect).To(pcpConnected),
fsm.FromGroup(pcpLive).On(evPcpKick).To(pcpDeleted),
)
func main() {
ctx := context.Background()
st := pcpConnected
_ = participantFSM.Fire(ctx, &st, evPcpDrop, disconnect{reason: "ice failed"})
fmt.Println("after drop:", st)
_ = participantFSM.Send(ctx, &st, evPcpReconnect)
fmt.Println("after reconnect:", st)
_ = participantFSM.Fire(ctx, &st, evPcpKick, disconnect{intentional: true, reason: "left the call"})
fmt.Println("after kick:", st)
// Nothing leaves a terminal state, so a kicked participant cannot be
// resurrected by a late event arriving out of order.
err := participantFSM.Send(ctx, &st, evPcpReconnect)
fmt.Println("late reconnect:", err)
fmt.Println("terminals:", participantFSM.Terminals())
// A group is a build-time grouping, so the kick is two ordinary rows in
// the table — but each remembers where it came from.
for _, e := range participantFSM.Edges() {
if e.Group != "" {
fmt.Printf("%v --%s--> %v inherited from %q\n", e.From, e.Event, e.To, e.Group)
}
}
fmt.Println("is reconnecting live?", pcpLive.Has(pcpReconnecting))
}
Output: after drop: reconnecting after reconnect: connected after kick: deleted late reconnect: fsm participant: no transition from deleted on reconnect terminals: [deleted] connected --kick--> deleted inherited from "live" reconnecting --kick--> deleted inherited from "live" is reconnecting live? true
Example (Recording) ¶
A recording moves active -> stopped -> finished -> uploaded. The order is declared once; every caller goes through Fire, so no call site can skip a step or forget a gauge.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/floatdrop/fsm"
)
// recState is int32-shaped so it can be stored directly in a protobuf enum
// field and survive a snapshot/restore round trip.
type recState int32
const (
recActive recState = iota
recStopped
recFinished
recUploaded
)
func (s recState) String() string {
return [...]string{"active", "stopped", "finished", "uploaded"}[s]
}
type recording struct {
state recState
stoppedAt time.Time
inProgressChunks int
inProgressTracks int
}
// Events are prefixed ev so they never read as states. Without it the pair
// "stop" and "stopped" is one tense apart, which is not a difference worth
// relying on.
//
// The payload is the aggregate itself, so guards and actions read the same
// data the caller has, with no package-level state and no type assertions.
var (
evRecStop = fsm.Define[*recording]("stop")
evRecFinish = fsm.Define[*recording]("finish")
evRecUpload = fsm.Signal("uploaded")
)
// A guard rejects with a sentinel, so a caller can tell "not finishable yet,
// retry later" apart from "not finishable at all" without parsing strings.
var errUploadPending = errors.New("still uploading")
func markStopped(_ context.Context, r *recording) error {
r.stoppedAt = time.Unix(1700000000, 0).UTC()
return nil
}
func uploadsSettled(_ context.Context, r *recording) error {
if r.inProgressChunks != 0 || r.inProgressTracks != 0 {
return fmt.Errorf("%w: %d chunks, %d tracks",
errUploadPending, r.inProgressChunks, r.inProgressTracks)
}
return nil
}
// Gauges tracking how many recordings sit in each state. In hand-written form
// these are a += and a -= at every site that changes the state, and they drift
// as soon as one site is missed.
var gauge = map[recState]int{}
func incr(s recState) func(context.Context) { return func(context.Context) { gauge[s]++ } }
func decr(s recState) func(context.Context) { return func(context.Context) { gauge[s]-- } }
// A machine is a set of rules. Each transition names its source and target in
// separate calls, so the two states cannot be swapped the way two adjacent
// arguments can.
var recordingFSM = fsm.MustNew("recording",
fsm.Gauge(recActive, incr(recActive), decr(recActive)),
fsm.Gauge(recStopped, incr(recStopped), decr(recStopped)),
fsm.Gauge(recFinished, incr(recFinished), decr(recFinished)),
fsm.Gauge(recUploaded, incr(recUploaded), decr(recUploaded)),
fsm.From(recActive).On(evRecStop).To(recStopped).Action(markStopped),
fsm.From(recStopped).On(evRecFinish).To(recFinished).
Guard("all chunks and tracks uploaded", uploadsSettled),
fsm.From(recFinished).On(evRecUpload).To(recUploaded),
)
func main() {
ctx := context.Background()
r := &recording{inProgressChunks: 2}
gauge[recActive]++ // the initial state is entered by construction
fmt.Println("state:", r.state)
if err := recordingFSM.Fire(ctx, &r.state, evRecStop, r); err != nil {
fmt.Println("unexpected:", err)
}
fmt.Println("after stop:", r.state, "at", r.stoppedAt.Format(time.RFC3339))
// Finishing early is refused by the guard, and the state does not move.
err := recordingFSM.Fire(ctx, &r.state, evRecFinish, r)
if ge, ok := errors.AsType[*fsm.GuardError[recState]](err); ok {
fmt.Printf("finish refused by %q: %v\n", ge.Guard, ge.Err)
fmt.Println("is upload pending?", errors.Is(err, errUploadPending), "| state still", r.state)
}
r.inProgressChunks = 0
if err := recordingFSM.Fire(ctx, &r.state, evRecFinish, r); err != nil {
fmt.Println("unexpected:", err)
}
if err := recordingFSM.Send(ctx, &r.state, evRecUpload); err != nil {
fmt.Println("unexpected:", err)
}
fmt.Println("final:", r.state)
fmt.Printf("gauges: active=%d stopped=%d finished=%d uploaded=%d\n",
gauge[recActive], gauge[recStopped], gauge[recFinished], gauge[recUploaded])
}
Output: state: active after stop: stopped at 2023-11-14T22:13:20Z finish refused by "all chunks and tracks uploaded": still uploading: 2 chunks, 0 tracks is upload pending? true | state still stopped final: uploaded gauges: active=0 stopped=0 finished=0 uploaded=1
Index ¶
- type Edge
- type Event
- type FromStep
- type Group
- type GuardError
- type Hook
- type Machine
- func (m *Machine[S]) Can[A any](ctx context.Context, from S, ev Event[A], arg A) bool
- func (m *Machine[S]) Check[A any](ctx context.Context, from S, ev Event[A], arg A) error
- func (m *Machine[S]) DOT() string
- func (m *Machine[S]) Edges() []Edge[S]
- func (m *Machine[S]) Fire[A any](ctx context.Context, st *S, ev Event[A], arg A) error
- func (m *Machine[S]) Groups() []Group[S]
- func (m *Machine[S]) Name() string
- func (m *Machine[S]) Send(ctx context.Context, st *S, ev Event[Unit]) error
- func (m *Machine[S]) States() []S
- func (m *Machine[S]) Terminals() []S
- func (m *Machine[S]) To[A any](from S, ev Event[A]) (S, bool)
- func (m *Machine[S]) Unreachable(initial S) []S
- type NoTransitionError
- type OnStep
- type Rule
- func Gauge[S comparable](s S, inc, dec func(context.Context)) Rule[S]
- func GaugeWith[S comparable, A any](s S, inc, dec func(context.Context, A)) Rule[S]
- func OnEnter[S comparable](s S, h Hook[S]) Rule[S]
- func OnEnterVia[S comparable, A any](s S, ev Event[A], h func(context.Context, Transition[S], A)) Rule[S]
- func OnExit[S comparable](s S, h Hook[S]) Rule[S]
- func OnExitVia[S comparable, A any](s S, ev Event[A], h func(context.Context, Transition[S], A)) Rule[S]
- type ToStep
- type Transition
- type Unit
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Edge ¶
type Edge[S comparable] struct { From S To S // Event is the trigger's name, for display. Names are not unique — // compare with [Edge.Is] to identify the trigger. Event string Guard string // guard description, empty when unguarded // Group names the group this edge was inherited from, and is empty for a // directly declared one. A group transition expands to one edge per // member, so this is what tells the expansion apart from N hand-written // rows. Group string // contains filtered or unexported fields }
Edge is a registered transition, reported by Machine.Edges.
func (Edge[S]) Is ¶ added in v0.3.0
Is reports whether ev is the trigger of this edge.
Branch on this rather than on Edge.Event, for the reason given on Transition.Is: events are identified by declaration, not by name.
type Event ¶
type Event[A any] struct { // contains filtered or unexported fields }
Event is a trigger carrying a payload of type A.
Events are declared once, typically as package-level variables:
var (
evStop = fsm.Define[time.Time]("stop")
evFinish = fsm.Signal("finish")
)
func Signal ¶
Signal declares an event with no payload. It is shorthand for Define[Unit] and is fired with Machine.Send.
type FromStep ¶
type FromStep[S comparable] struct { // contains filtered or unexported fields }
FromStep is a transition with its source states fixed. It is a transient value in a From chain; call FromStep.On to continue.
func From ¶
func From[S comparable](s S) FromStep[S]
From begins a transition declaration:
fsm.From(recStopped).On(evRecFinish).To(recFinished).
Guard("all chunks and tracks uploaded", uploadsSettled)
The source and target states are named by separate calls rather than passed as two adjacent arguments of the same type, so they cannot be swapped by mistake.
func FromEach ¶ added in v0.3.0
func FromEach[S comparable](ss ...S) FromStep[S]
FromEach begins a transition declaration with several source states: the rule it produces registers one transition per source, all on the same event and to the same target.
fsm.FromEach(pcpConnected, pcpReconnecting).On(evPcpKick).To(pcpDeleted)
It is a fan-in shorthand, not a hierarchy — the sources are enumerated at this declaration and the machine learns nothing that relates them. When they are related, and especially when one of them needs to handle the event differently, declare a Group and use FromGroup instead.
A source list computed elsewhere can be spread into it directly:
fsm.FromEach(liveStates...).On(evAbort).To(dead)
func FromGroup ¶ added in v0.3.0
func FromGroup[S comparable](g Group[S]) FromStep[S]
FromGroup begins a transition declaration whose source is every member of g that does not declare the event itself. See Group.
type Group ¶ added in v0.3.0
type Group[S comparable] struct { // contains filtered or unexported fields }
Group is a named set of states that share transitions. A transition declared with FromGroup applies to every member that does not declare that event itself, so adding a member inherits the group's transitions and a member can still specialise one:
var pcpLive = fsm.NewGroup("live", pcpConnected, pcpReconnecting)
fsm.FromGroup(pcpLive).On(evPcpKick).To(pcpDeleted) // both members
fsm.From(pcpReconnecting).On(evPcpKick).To(pcpAbandoned) // overrides it
Membership is declared in one place, which is the difference from FromEach: enumerating the sources at each transition means a new member silently inherits nothing.
A Group is not a state. A *S never holds one and it does not appear in Machine.States — it is a declaration-time grouping that expands to ordinary rows in the transition table, so Machine.Fire neither knows nor pays for it. Group entry and exit hooks are deliberately absent; see the package documentation for what that rules out.
A Group is itself a Rule, so a group used only for Group.Has or for DOT output can be passed to New on its own.
func NewGroup ¶ added in v0.3.0
func NewGroup[S comparable](name string, members ...S) Group[S]
NewGroup declares a group of states. name labels it in errors and in DOT output, and must be unique within a machine.
func (Group[S]) Has ¶ added in v0.3.0
Has reports whether s is a member of the group. It is the flat equivalent of a hierarchical "is the machine anywhere inside this superstate".
type GuardError ¶
type GuardError[S comparable] struct { Machine string From S To S Event string Guard string // the guard's static description, empty when unnamed Err error }
GuardError reports a transition that exists but was rejected by a guard.
Err is the error the guard returned. GuardError unwraps to it, so a guard can reject with a sentinel and the caller can match it directly:
if errors.Is(err, ErrChunksPending) { ... }
func (*GuardError[S]) Error ¶
func (e *GuardError[S]) Error() string
func (*GuardError[S]) Unwrap ¶
func (e *GuardError[S]) Unwrap() error
Unwrap returns the error the guard rejected with.
type Hook ¶
type Hook[S comparable] func(context.Context, Transition[S])
Hook is a state entry/exit callback. Hooks cannot fail: they exist for bookkeeping that must stay paired with the state change, such as incrementing and decrementing a gauge. Work that can fail belongs in an action registered with ToStep.Action, which runs before the state changes and aborts the transition on error.
A Hook does not see the event's payload, because a state can be entered by events carrying different types. When the payload is what the hook is for, declare it with OnEnterVia or OnExitVia, which name the event and so know its type.
type Machine ¶
type Machine[S comparable] struct { // contains filtered or unexported fields }
Machine is an immutable state machine built by New from a set of [Rule]s.
A Machine holds no state and is safe for concurrent use.
func MustNew ¶
func MustNew[S comparable](name string, rules ...Rule[S]) *Machine[S]
MustNew is New for package-level variables. It panics on a bad definition, which surfaces at process start rather than at fire time.
func New ¶
func New[S comparable](name string, rules ...Rule[S]) (*Machine[S], error)
New builds a machine from its rules. name appears in errors and DOT output.
S is inferred from the rules, so it rarely has to be written out. A machine with no rules cannot infer it, and is an error in any case.
func (*Machine[S]) Can ¶
Can reports whether firing ev from state from would succeed. It is Machine.Check with the reason discarded.
func (*Machine[S]) Check ¶
Check reports why firing ev from state from would fail, evaluating guards but running no action and no hook. It returns nil when the transition would be allowed, and otherwise the same error Machine.Fire would return.
Use Check over Machine.Can when the reason matters — to report it, or to match a sentinel with errors.Is.
func (*Machine[S]) DOT ¶
DOT renders the machine as a Graphviz digraph. A Group is drawn as a cluster, and a transition every member inherited is drawn once from the cluster boundary rather than once per member.
Output is deterministic: states, edges and groups are emitted in declaration order, never in map order, so the result can be committed and diffed.
Example ¶
DOT output is stable across runs, so it can be committed next to the code and reviewed as part of a diff when the machine changes.
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/floatdrop/fsm"
)
// recState is int32-shaped so it can be stored directly in a protobuf enum
// field and survive a snapshot/restore round trip.
type recState int32
const (
recActive recState = iota
recStopped
recFinished
recUploaded
)
func (s recState) String() string {
return [...]string{"active", "stopped", "finished", "uploaded"}[s]
}
type recording struct {
state recState
stoppedAt time.Time
inProgressChunks int
inProgressTracks int
}
// Events are prefixed ev so they never read as states. Without it the pair
// "stop" and "stopped" is one tense apart, which is not a difference worth
// relying on.
//
// The payload is the aggregate itself, so guards and actions read the same
// data the caller has, with no package-level state and no type assertions.
var (
evRecStop = fsm.Define[*recording]("stop")
evRecFinish = fsm.Define[*recording]("finish")
evRecUpload = fsm.Signal("uploaded")
)
// A guard rejects with a sentinel, so a caller can tell "not finishable yet,
// retry later" apart from "not finishable at all" without parsing strings.
var errUploadPending = errors.New("still uploading")
func markStopped(_ context.Context, r *recording) error {
r.stoppedAt = time.Unix(1700000000, 0).UTC()
return nil
}
func uploadsSettled(_ context.Context, r *recording) error {
if r.inProgressChunks != 0 || r.inProgressTracks != 0 {
return fmt.Errorf("%w: %d chunks, %d tracks",
errUploadPending, r.inProgressChunks, r.inProgressTracks)
}
return nil
}
// Gauges tracking how many recordings sit in each state. In hand-written form
// these are a += and a -= at every site that changes the state, and they drift
// as soon as one site is missed.
var gauge = map[recState]int{}
func incr(s recState) func(context.Context) { return func(context.Context) { gauge[s]++ } }
func decr(s recState) func(context.Context) { return func(context.Context) { gauge[s]-- } }
// A machine is a set of rules. Each transition names its source and target in
// separate calls, so the two states cannot be swapped the way two adjacent
// arguments can.
var recordingFSM = fsm.MustNew("recording",
fsm.Gauge(recActive, incr(recActive), decr(recActive)),
fsm.Gauge(recStopped, incr(recStopped), decr(recStopped)),
fsm.Gauge(recFinished, incr(recFinished), decr(recFinished)),
fsm.Gauge(recUploaded, incr(recUploaded), decr(recUploaded)),
fsm.From(recActive).On(evRecStop).To(recStopped).Action(markStopped),
fsm.From(recStopped).On(evRecFinish).To(recFinished).
Guard("all chunks and tracks uploaded", uploadsSettled),
fsm.From(recFinished).On(evRecUpload).To(recUploaded),
)
func main() {
fmt.Print(recordingFSM.DOT())
}
Output: digraph "recording" { rankdir=LR; "active" [shape=box]; "stopped" [shape=box]; "finished" [shape=box]; "uploaded" [shape=doublecircle]; "active" -> "stopped" [label="stop"]; "stopped" -> "finished" [label="finish\n[all chunks and tracks uploaded]"]; "finished" -> "uploaded" [label="uploaded"]; }
Example (Group) ¶
A group is drawn as a cluster, and a transition every member inherited is drawn once from the cluster boundary rather than once per member — which is the whole visual point of grouping the states in the first place.
package main
import (
"context"
"fmt"
"github.com/floatdrop/fsm"
)
type pcpState int32
const (
pcpConnected pcpState = iota
pcpReconnecting
pcpDeleted
)
func (s pcpState) String() string {
return [...]string{"connected", "reconnecting", "deleted"}[s]
}
// A disconnect carries why it happened, so the reason cannot be lost between
// the caller and the action that logs it.
type disconnect struct {
intentional bool
reason string
}
var (
evPcpDrop = fsm.Define[disconnect]("disconnect")
evPcpReconnect = fsm.Signal("reconnect")
evPcpKick = fsm.Define[disconnect]("kick")
)
func unintentional(_ context.Context, d disconnect) error {
if d.intentional {
return fmt.Errorf("intentional disconnect (%s) must be a kick, not a drop", d.reason)
}
return nil
}
// A participant is kicked the same way whether it is connected or
// reconnecting. Naming the two as a group says that once: a third live state
// inherits the kick instead of needing the rule copied, and the group is still
// the place to look for what "live" means.
var pcpLive = fsm.NewGroup("live", pcpConnected, pcpReconnecting)
// Modelling a participant makes deletion an explicit terminal state rather
// than "absent from the map", so the last transition is expressible and can
// carry a reason.
var participantFSM = fsm.MustNew("participant",
fsm.From(pcpConnected).On(evPcpDrop).To(pcpReconnecting).
Guard("disconnect was not intentional", unintentional),
fsm.From(pcpReconnecting).On(evPcpReconnect).To(pcpConnected),
fsm.FromGroup(pcpLive).On(evPcpKick).To(pcpDeleted),
)
func main() {
fmt.Print(participantFSM.DOT())
}
Output: digraph "participant" { rankdir=LR; compound=true; subgraph "cluster_live" { label="live"; style=rounded; "connected" [shape=box]; "reconnecting" [shape=box]; } "deleted" [shape=doublecircle]; "connected" -> "reconnecting" [label="disconnect\n[disconnect was not intentional]"]; "reconnecting" -> "connected" [label="reconnect"]; "connected" -> "deleted" [label="kick", ltail="cluster_live"]; }
func (*Machine[S]) Edges ¶
Edges returns every registered transition, in declaration order. Rows a group transition expanded to carry the group's name in Edge.Group.
func (*Machine[S]) Fire ¶
Fire applies ev to the state pointed to by st.
The order of operations is: look up the transition, evaluate guards, run the action, run the exit hooks of the old state, assign the new state, run the entry hooks of the new state. If the lookup fails, a guard rejects, or the action returns an error, *st is left untouched and no hook runs.
Fire is a generic method: A is inferred from ev, so the payload is checked at compile time.
A guard, action or exit hook must not fire this machine on st: the outer assignment overwrites its change. Entry hooks run after it and may.
func (*Machine[S]) Groups ¶ added in v0.3.0
Groups returns every declared group, in declaration order.
func (*Machine[S]) States ¶
func (m *Machine[S]) States() []S
States returns every state mentioned in the definition, in declaration order.
func (*Machine[S]) Terminals ¶
func (m *Machine[S]) Terminals() []S
Terminals returns the states with no outgoing transition.
A machine's terminal set is worth asserting in a test: an unintended terminal state is a state something can get stuck in.
func (*Machine[S]) Unreachable ¶
func (m *Machine[S]) Unreachable(initial S) []S
Unreachable returns the states that cannot be reached from initial by any sequence of transitions, ignoring guards. initial itself is always considered reachable.
type NoTransitionError ¶
type NoTransitionError[S comparable] struct { Machine string From S Event string }
NoTransitionError reports an event fired from a state that does not accept it. Match it with errors.AsType.
func (*NoTransitionError[S]) Error ¶
func (e *NoTransitionError[S]) Error() string
type OnStep ¶
type OnStep[S comparable, A any] struct { // contains filtered or unexported fields }
OnStep is a transition with its source states and event fixed. It is a transient value in a From chain; call OnStep.To to complete it.
func (OnStep[S, A]) To ¶
To completes the transition. The result is a Rule ready for New, and also the place to hang guards and actions:
fsm.From(a).On(ev).To(b).Guard("...", g).Action(f)
A self-transition — To naming the state On came from — is allowed and runs the full sequence: the exit hooks of the state, the assignment, then its entry hooks. That is UML's external self-transition, and it means a Gauge on the state decrements and increments back to where it was. There is no internal transition that skips the hooks; use an ToStep.Action for that.
type Rule ¶
type Rule[S comparable] interface { // contains filtered or unexported methods }
Rule is one declaration a machine is made of: a transition, a gauge or a hook. Rules are values, so a set of them can be built up in a loop, stored, and shared between machines.
Only this package implements Rule. Declare one with From, Gauge, OnEnter or OnExit.
func Gauge ¶
func Gauge[S comparable](s S, inc, dec func(context.Context)) Rule[S]
Gauge pairs an increment on entering s with a decrement on leaving it.
This is the hook pattern worth having a name for: a counter that tracks "how many things are currently in state s" is otherwise maintained by hand at every call site that changes the state, and drifts the moment one of them is missed.
func GaugeWith ¶ added in v0.2.0
func GaugeWith[S comparable, A any](s S, inc, dec func(context.Context, A)) Rule[S]
GaugeWith is Gauge for a counter that needs something from the event's payload — the instance the count is labelled by, typically:
fsm.GaugeWith(recStopped,
func(_ context.Context, s recStep) { s.r.metrics.Add(recStopped, 1) },
func(_ context.Context, s recStep) { s.r.metrics.Add(recStopped, -1) },
)
Gauge's closures are fixed when the machine is built, which is no good when the counter belongs to whatever the transition is about. GaugeWith is still declared once per state, so the increment and its decrement stay bound together: it expands to an entry hook on every event that enters s and an exit hook on every event that leaves s.
That requires every event touching s to carry the same payload type A. New checks it and reports the offending transition rather than letting a mismatched event silently skip the counter, so a machine whose events carry different payloads can still use GaugeWith on the states where they agree.
Entering the initial state is not a transition, so the first increment is the caller's, exactly as with Gauge.
func OnEnter ¶
func OnEnter[S comparable](s S, h Hook[S]) Rule[S]
OnEnter declares a hook that runs just after the machine enters s. Hooks run in declaration order.
func OnEnterVia ¶
func OnEnterVia[S comparable, A any](s S, ev Event[A], h func(context.Context, Transition[S], A)) Rule[S]
OnEnterVia declares a hook that runs just after the machine enters s, but only when ev is what put it there — and hands the hook that event's payload:
fsm.OnEnterVia(pcpDeleted, evPcpKick, func(_ context.Context, tr fsm.Transition[pcpState], d disconnect) {
log.Info("participant kicked", "reason", d.reason)
})
A plain OnEnter hook cannot do this: a state can be entered by events carrying different payload types, so there is no single type to hand it. Naming the event fixes A, which is what makes the hook typed.
Like OnEnter, it runs after the state has changed and cannot fail. It runs after the plain entry hooks of the same state.
func OnExit ¶
func OnExit[S comparable](s S, h Hook[S]) Rule[S]
OnExit declares a hook that runs just before the machine leaves s.
func OnExitVia ¶
func OnExitVia[S comparable, A any](s S, ev Event[A], h func(context.Context, Transition[S], A)) Rule[S]
OnExitVia declares a hook that runs just before the machine leaves s, but only when ev is what moves it — and hands the hook that event's payload. See OnEnterVia. It runs after the plain exit hooks of the same state, and still before the state changes.
type ToStep ¶
type ToStep[S comparable, A any] struct { // contains filtered or unexported fields }
ToStep is a complete transition, optionally carrying guards and actions. It satisfies Rule.
Its methods mutate and return the same value, so a guard attached to a stored ToStep takes effect whether or not the result is reassigned.
func (*ToStep[S, A]) Action ¶
Action runs f as part of the transition. If f returns an error the transition is aborted and the state is left unchanged. Actions run in the order they are attached, after every guard has passed.
func (*ToStep[S, A]) Guard ¶
Guard rejects the transition when f returns a non-nil error. The error is wrapped in a GuardError, which unwraps to it, so a guard can reject with a sentinel the caller matches using errors.Is.
desc is the guard's static description: it labels the edge in DOT output and appears in the error, so it should read as the condition being enforced — "all chunks and tracks uploaded". The returned error carries the dynamic reason the condition did not hold this time.
A guard must be pure: it is also evaluated by Machine.Check and Machine.Can. Guards run in the order they are attached and the first rejection wins.
type Transition ¶
type Transition[S comparable] struct { From S To S // Event is the trigger's name, for logging. Names are not unique — // compare with [Transition.Is] to identify the trigger. Event string // contains filtered or unexported fields }
Transition describes a state change that is about to happen or has just happened. It is passed to the hooks declared with OnEnter and OnExit.
func (Transition[S]) Is ¶
func (t Transition[S]) Is[A any](ev Event[A]) bool
Is reports whether ev triggered the transition.
Branch on this rather than on Transition.Event: events are identified by declaration, not by name, so two events declared with the same name are different triggers that Event cannot tell apart.