fsm

package module
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 23, 2026 License: MIT Imports: 5 Imported by: 0

README

fsm

CI Go Reference License

A small finite state machine for Go. The caller owns the state, events carry typed payloads, and a machine is a set of rules whose source and target are named in separate calls so they cannot be swapped.

Requires Go 1.27. The API uses generic methods, which earlier versions reject with method must have no type parameters.

type recState int32

const (
    recActive recState = iota
    recStopped
    recFinished
    recUploaded
)

// Events are prefixed ev so an event and a state never differ by tense alone.
var (
    evRecStop   = fsm.Define[*recording]("stop")
    evRecFinish = fsm.Define[*recording]("finish")
    evRecUpload = fsm.Signal("uploaded")
)

var recordingFSM = fsm.MustNew("recording",
    fsm.Initial(recActive),
    fsm.Gauge(recActive, metrics.Inc(recActive), metrics.Dec(recActive)),
    fsm.Gauge(recStopped, metrics.Inc(recStopped), metrics.Dec(recStopped)),

    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),
)

// The state lives in your struct. Fire mutates it in place and reports the move.
tr, err := recordingFSM.Fire(ctx, &r.state, evRecStop, r)
// tr.From == recActive, tr.To == recStopped

An event the current state does not accept returns an error, never panics, and leaves the state untouched:

_, err := recordingFSM.Send(ctx, &r.state, evRecUpload)
// fsm recording: no transition from active on uploaded

Every fire-time failure is a typed error carrying the edge: *NoTransitionError, *GuardError, *ActionError, or *StateChangedError when a guard or action wrote the state through an aliased payload, reported ahead of any error that callback also returned (kept in its Err, but not unwrapped, since the state did move). GuardError and ActionError unwrap to the guard's or action's own error, so a sentinel can be matched with errors.Is. A nil state pointer or the zero Event is a plain error, since it is a bug in the caller.

The reasoning behind the API (state ownership, typed payloads, Rule as the single option type, guard semantics, known limitations) is in docs/DESIGN.md.

Shared transitions

FromEach declares the same transition from several sources:

fsm.FromEach(pcpConnected, pcpReconnecting).On(evPcpKick).To(pcpDeleted)

The machine records one edge per source and nothing that relates them. When the sources are related, use a group.

Groups

NewGroup names a set of states. A transition declared on the group applies to every member:

type pcpState int32

const (
    pcpConnected pcpState = iota
    pcpReconnecting
    pcpDeleted
)

// A disconnect carries whether it was intentional, and why.
var (
    evPcpDrop      = fsm.Define[disconnect]("disconnect")
    evPcpReconnect = fsm.Signal("reconnect")
    evPcpKick      = fsm.Define[disconnect]("kick")
)

// Connected and reconnecting are both live, and both are kicked the same way.
var pcpLive = fsm.NewGroup("live", pcpConnected, pcpReconnecting)

var participantFSM = fsm.MustNew("participant",
    fsm.Initial(pcpConnected),
    fsm.From(pcpConnected).On(evPcpDrop).To(pcpReconnecting).
        Guard("disconnect was not intentional", unintentional),
    fsm.From(pcpReconnecting).On(evPcpReconnect).To(pcpConnected),

    // One rule for every live state, including any added later.
    fsm.FromGroup(pcpLive).On(evPcpKick).To(pcpDeleted),
)

In the diagram the group is a cluster, and kick leaves the cluster rather than any one state inside it:

a start dot pointing at connected, which sits with reconnecting inside a cluster labelled live, with kick leaving the cluster boundary for deleted

A member that declares the event itself overrides the group rule. The other members keep the inherited one:

fsm.From(pcpReconnecting).On(evPcpKick).To(pcpAbandoned) // overrides the group

This override is what FromEach cannot do. With enumerated sources there is nothing to override, and a state added later has to be added to every FromEach call by hand.

A group is not a state. It never appears in States(), a *S never holds one, and it expands into ordinary rows before New returns, so Fire does not know about groups and pays nothing for them. Each expanded row records its group:

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)
    }
}
// connected --kick--> deleted inherited from "live"
// reconnecting --kick--> deleted inherited from "live"

New rejects a member that is not a state of the machine, two groups claiming one event for the same state, a group transition every member overrides, a repeated member, and a name reused for a different set of members.

Groups cover most of what substates are used for, but not all of it: there are no group hooks or Gauge, no nesting, and no initial member. The reasons are in docs/DESIGN.md.

Hooks

OnEnter and OnExit run bookkeeping that cannot fail around the assignment, and Gauge pairs an increment on entry with a decrement on exit. OnTransition runs after every transition, for an audit log or a trace:

fsm.OnTransition(func(_ context.Context, tr fsm.Transition[recState]) {
    log.Info("recording", "from", tr.From, "to", tr.To, "on", tr.Event())
}),

A transition hook observes and must not fire the machine itself; entry hooks may. A hook sees the transition, not the payload, because a state can be entered by events carrying different types. OnEnterVia and OnExitVia name the event, which fixes the payload type (New rejects one that no transition on that event can trigger), and GaugeWith is Gauge for a counter that lives in the payload.

The order inside Fire is fixed: lookup, guards, action, the check that neither wrote the state, exit hooks, assignment, transition hooks, entry hooks. Everything that can fail does so before the assignment, so a hook never runs for a transition that did not happen.

Introspection

States, Events, Edges, Terminals and Unreachable report the machine's shape in declaration order, so tests can assert on them:

func TestRecordingShape(t *testing.T) {
    // Only uploaded is a dead end.
    if got := recordingFSM.Terminals(); len(got) != 1 || got[0] != recUploaded {
        t.Errorf("terminals %v, want [uploaded]", got)
    }
    // Every state is reachable from active.
    if got := recordingFSM.Unreachable(recActive); len(got) != 0 {
        t.Errorf("unreachable states: %v", got)
    }
}

An unintended terminal state is somewhere a value can get stuck, and an unreachable state usually means a missing transition. Both are cheap to test.

Initial declares where a fresh instance starts, as both machines above do. The machine still holds no state, but New then rejects a state nothing reaches from the start, or a start with no way out, so the reachability check above is already a build error:

fsm.MustNew("recording",
    fsm.Initial(recActive),
    fsm.From(recActive).On(evRecStop).To(recStopped),
    fsm.From(recFinished).On(evRecUpload).To(recUploaded), // nothing reaches finished
)
// panic: fsm recording: states [finished uploaded] cannot be reached from initial state active

Edges returns the transition table, including guard descriptions:

for _, e := range recordingFSM.Edges() {
    fmt.Printf("%v --%s--> %v  %s\n", e.From, e.Event(), e.To, e.Guard)
}
// active --stop--> stopped
// stopped --finish--> finished  all chunks and tracks uploaded
// finished --uploaded--> uploaded

Edge.Event() is the trigger's name and is meant for display. Names are not unique, so identify a trigger with e.Is(evRecStop).

DOT

DOT renders the machine as a Graphviz digraph. The initial state is pointed at from a dot, terminal states are double circles, and guards appear in edge labels:

fmt.Print(recordingFSM.DOT())
digraph "recording" {
	rankdir=LR;
	"__start" [shape=point];
	"active" [shape=box];
	"stopped" [shape=box];
	"finished" [shape=box];
	"uploaded" [shape=doublecircle];
	"__start" -> "active";
	"active" -> "stopped" [label="stop"];
	"stopped" -> "finished" [label="finish\n[all chunks and tracks uploaded]"];
	"finished" -> "uploaded" [label="uploaded"];
}
go run ./yourcmd | dot -Tsvg -o machine.svg

a start dot pointing at active, active to stopped on stop, stopped to finished on finish guarded by all chunks and tracks uploaded, finished to uploaded

A group becomes a cluster, and a transition every member inherited is drawn once from the cluster boundary:

compound=true;
subgraph "cluster_live" {
	label="live";
	style=rounded;
	"connected" [shape=box];
	"reconnecting" [shape=box];
}
"connected" -> "deleted" [label="kick", ltail="cluster_live"];

Per-member arrows are used when a boundary arrow would be wrong: a member overrides the event, the group overlaps another and cannot hold all its members in one cluster, or the target is itself a member.

Output follows declaration order, so the DOT can be committed next to the code and diffed when the machine changes.

Benchmark

Fire does not allocate. Guards and actions are combined at construction, where the payload type is known, and stored as a concrete func(context.Context, A) error. Fire asserts back to that type and passes the payload directly, so nothing is boxed.

go test -bench Fire -benchmem -run '^$' ./...
goos: darwin
goarch: arm64
cpu: Apple M3 Pro
BenchmarkFire-14             37311288    32.04 ns/op    0 B/op    0 allocs/op
BenchmarkFireWithHooks-14    18780339    63.72 ns/op    0 B/op    0 allocs/op

One iteration is a round trip of two fires, one with an action, so a single Fire is about 16 ns. BenchmarkFireWithHooks runs the same round trip on a machine with an entry hook, an exit hook and both payload hooks. A machine that declares no hooks skips the hook block on one flag check and pays none of that.

TestFireDoesNotAllocate pins the zero allocations. It runs without -race, which changes the allocation profile.

Single runs vary by about 10%. Re-measure with -count=6 before updating these numbers.

License

MIT, see LICENSE.

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.Initial(pcpConnected),
	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.Initial(recActive),
	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

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type ActionError added in v0.4.0

type ActionError[S comparable] struct {
	Machine string
	From    S
	To      S
	Event   string
	Err     error
}

ActionError reports a transition whose action failed. The state is untouched and no hook ran.

Err is the error the action returned, and ActionError unwraps to it.

func (*ActionError[S]) Error added in v0.4.0

func (e *ActionError[S]) Error() string

func (*ActionError[S]) Unwrap added in v0.4.0

func (e *ActionError[S]) Unwrap() error

Unwrap returns the error the action failed with.

type Edge

type Edge[S comparable] struct {
	From S
	To   S

	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]) Event

func (e Edge[S]) Event() string

Event returns the trigger's name, for display. Names are not unique; compare with Edge.Is to identify the trigger.

func (Edge[S]) Is added in v0.3.0

func (e Edge[S]) Is[A any](ev Event[A]) bool

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 Define

func Define[A any](name string) Event[A]

Define declares an event carrying a payload of type A.

func Signal

func Signal(name string) Event[Unit]

Signal declares an event with no payload. It is shorthand for Define[Unit] and is fired with Machine.Send.

func (Event[A]) Name

func (e Event[A]) Name() string

Name returns the event's declared name. The zero Event reports "<invalid>".

func (Event[A]) String

func (e Event[A]) String() string

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.

func (FromStep[S]) On

func (f FromStep[S]) On[A any](ev Event[A]) OnStep[S, A]

On names the event that triggers the transition.

On is a generic method: A is inferred from ev and carried through to the rest of the chain, so a guard or action whose payload type does not match the event is a compile error rather than a runtime surprise.

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

func (g Group[S]) Has(s S) bool

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".

func (Group[S]) Members added in v0.3.0

func (g Group[S]) Members() []S

Members returns the group's states, in declaration order.

func (Group[S]) Name added in v0.3.0

func (g Group[S]) Name() string

Name returns the group's declared name.

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

func (m *Machine[S]) Can[A any](ctx context.Context, from S, ev Event[A], arg A) bool

Can reports whether firing ev from state from would succeed. It is Machine.Check with the reason discarded.

func (*Machine[S]) Check

func (m *Machine[S]) Check[A any](ctx context.Context, from S, ev Event[A], arg A) error

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

func (m *Machine[S]) DOT() string

DOT renders the machine as a Graphviz digraph. A Group is drawn as a cluster, a transition every member inherited is drawn once from the cluster boundary rather than once per member, and the Initial state, if declared, is pointed at from a dot.

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.Initial(recActive),
	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;
	"__start" [shape=point];
	"active" [shape=box];
	"stopped" [shape=box];
	"finished" [shape=box];
	"uploaded" [shape=doublecircle];
	"__start" -> "active";
	"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.Initial(pcpConnected),
	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;
	"__start" [shape=point];
	subgraph "cluster_live" {
		label="live";
		style=rounded;
		"connected" [shape=box];
		"reconnecting" [shape=box];
	}
	"deleted" [shape=doublecircle];
	"__start" -> "connected";
	"connected" -> "reconnecting" [label="disconnect\n[disconnect was not intentional]"];
	"reconnecting" -> "connected" [label="reconnect"];
	"connected" -> "deleted" [label="kick", ltail="cluster_live"];
}

func (*Machine[S]) Edges

func (m *Machine[S]) Edges() []Edge[S]

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]) Events added in v0.4.0

func (m *Machine[S]) Events() []string

Events returns the distinct names of the events some transition reacts to, in declaration order. Names are for display; two events sharing one are listed once.

func (*Machine[S]) Fire

func (m *Machine[S]) Fire[A any](ctx context.Context, st *S, ev Event[A], arg A) (Transition[S], error)

Fire applies ev to the state pointed to by st and returns the transition it made.

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 OnTransition hooks, then the entry hooks of the new state. If the lookup fails, a guard rejects, or the action returns an error, Fire assigns nothing, runs no hook, and returns the zero Transition.

The payload often aliases the state. A guard or action that writes *st, or fires this machine on it, is reported as a StateChangedError, again with nothing assigned and no hook run. An exit hook that does so is overwritten by the assignment. A transition hook runs between the assignment and the entry hooks and must not fire either, since the new state is not fully entered. Entry hooks run last and may fire.

Fire is a generic method: A is inferred from ev, so the payload is checked at compile time.

func (*Machine[S]) Groups added in v0.3.0

func (m *Machine[S]) Groups() []Group[S]

Groups returns every declared group, in declaration order.

func (*Machine[S]) Initial added in v0.4.0

func (m *Machine[S]) Initial() (S, bool)

Initial returns the state declared with Initial, and false when none was.

func (*Machine[S]) Name

func (m *Machine[S]) Name() string

Name returns the machine's name, used in error messages and DOT output.

func (*Machine[S]) Send

func (m *Machine[S]) Send(ctx context.Context, st *S, ev Event[Unit]) (Transition[S], error)

Send fires an event that carries no payload. It is Machine.Fire with the payload fixed to Unit{}.

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]) To

func (m *Machine[S]) To[A any](from S, ev Event[A]) (S, bool)

To returns the state that ev leads to from state from, ignoring guards.

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

func (o OnStep[S, A]) To(to S) *ToStep[S, A]

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, OnExit, OnTransition, Initial or Rules.

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 Initial added in v0.4.0

func Initial[S comparable](s S) Rule[S]

Initial declares the state a fresh instance starts in. The machine still holds no state, so nothing changes at fire time; it lets New reject a machine in which some state cannot be reached from the start, or whose start has no way out, and lets Machine.DOT mark where the machine begins.

Entering the initial state is not a transition, so no hook runs for it and the first increment of a Gauge is the caller's.

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. New rejects it when no transition on ev enters s, since it could never run.

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.

func OnTransition added in v0.4.0

func OnTransition[S comparable](h Hook[S]) Rule[S]

OnTransition declares a hook that runs after every transition, just after the assignment and before the entry hooks of the new state, so an entry hook that fires the machine again is logged after the transition that caused it. It is where an audit log or a trace that must see every change goes, instead of one OnEnter per state.

A transition hook observes. It must not fire the machine on the same state: the entry hooks of the new state have not run yet, so a nested transition would leave that state before entering it.

func Rules added in v0.4.0

func Rules[S comparable](rules ...Rule[S]) Rule[S]

Rules bundles several rules into one, so a set shared between machines can be passed to New beside other rules without splicing slices.

type StateChangedError added in v0.4.0

type StateChangedError[S comparable] struct {
	Machine string
	From    S // the state the transition started from
	To      S // the state it was going to
	Found   S // the state the guard or action left behind
	Event   string
	Guard   string // the rejecting guard's description, empty otherwise
	Err     error  // what the guard or action also returned, nil if it succeeded
}

StateChangedError reports that a guard or action wrote the state, which happens when the payload aliases it. The write is left in place; the machine assigns nothing and runs no hook. It takes precedence over a rejection or failure from the same callback, whose error is kept in Err.

It deliberately does not unwrap to Err: a guard's sentinel means "state untouched, retry", and here the state was touched.

func (*StateChangedError[S]) Error added in v0.4.0

func (e *StateChangedError[S]) Error() string

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

func (t *ToStep[S, A]) Action(f func(context.Context, A) error) *ToStep[S, A]

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

func (t *ToStep[S, A]) Guard(desc string, f func(context.Context, A) error) *ToStep[S, A]

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
	// 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]) Event

func (t Transition[S]) Event() string

Event returns the trigger's name, for logging. Names are not unique; compare with Transition.Is to identify the trigger.

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.

type Unit

type Unit = struct{}

Unit is the payload type of events that carry no data. See Signal.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL