Skip to content
floatdropPublic

About

A small finite state machine for Go 1.27+

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Gopher dance

fsm

CI Go Reference Coverage License Awesome Go

A finite state machine for Go.

go get github.com/floatdrop/fsm

Quick start

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/floatdrop/fsm"
)

// States are any comparable type. int32 here so the state can sit in a
// protobuf enum field and survive a snapshot.
type recState int32

const (
	recActive recState = iota
	recStopped
	recFinished
	recUploaded
)

func (s recState) String() string {
	return [...]string{"active", "stopped", "finished", "uploaded"}[s]
}

// The state lives in your struct. The machine never holds a copy.
type recording struct {
	state          recState
	stoppedAt      time.Time
	pendingUploads int
}

// Events are prefixed ev so they never read as states: "stop" the event beside
// "stopped" the state is one tense apart. The payload is the aggregate itself,
// so guards and actions see exactly what the caller has.
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 yet, retry later"
// from "not allowed at all" without parsing strings.
var errUploadPending = errors.New("still uploading")

var recordingFSM = fsm.MustNew("recording",
	fsm.Initial(recActive),

	fsm.From(recActive).On(evRecStop).To(recStopped).
		Action(func(_ context.Context, r *recording) error {
			r.stoppedAt = time.Now()
			return nil
		}),

	fsm.From(recStopped).On(evRecFinish).To(recFinished).
		Guard("all chunks and tracks uploaded", func(_ context.Context, r *recording) error {
			if r.pendingUploads != 0 {
				return fmt.Errorf("%w: %d left", errUploadPending, r.pendingUploads)
			}
			return nil
		}),

	fsm.From(recFinished).On(evRecUpload).To(recUploaded),
)

func main() {
	ctx := context.Background()
	r := &recording{pendingUploads: 2}

	// Fire moves the state in place and reports the transition it made.
	tr, _ := recordingFSM.Fire(ctx, &r.state, evRecStop, r)
	fmt.Println(tr.From, "->", tr.To, "on", tr.Event())

	// The guard refuses; the state does not move.
	_, err := recordingFSM.Fire(ctx, &r.state, evRecFinish, r)
	fmt.Println(err)
	fmt.Println("retryable:", errors.Is(err, errUploadPending), "| state:", r.state)

	// An event this state does not accept is an error, never a panic.
	_, err = recordingFSM.Send(ctx, &r.state, evRecUpload)
	fmt.Println(err)

	r.pendingUploads = 0
	_, _ = recordingFSM.Fire(ctx, &r.state, evRecFinish, r)
	_, _ = recordingFSM.Send(ctx, &r.state, evRecUpload)
	fmt.Println("final:", r.state)
}
active -> stopped on stop
fsm recording: transition stopped --finish--> finished rejected by guard "all chunks and tracks uploaded": still uploading: 2 left
retryable: true | state: stopped
fsm recording: no transition from stopped on uploaded
final: uploaded

Every section below refers back to this machine.

Declaring a machine

New returns a machine or every mistake it found. MustNew panics instead — right for a package-level variable, so a bad definition fails at startup rather than at fire time.

Each of these returns a Rule[S], the single option type. Rules are plain values, reusable across machines.

Rule Declares
Initial(a) where a fresh instance starts
From(a).On(ev).To(b) one transition
FromEach(a, b).On(ev).To(c) the same transition from several sources
FromGroup(g).On(ev).To(c) a transition inherited by every member of a group
From(a).On(ev).Stay() an internal transition: the event is handled in a, which it does not leave
OnEnter(s, h) / OnExit(s, h) hooks around the assignment
OnEnterVia(s, ev, h) / OnExitVia(s, ev, h) hooks that see the payload of one event
OnEnterWith(s, h) / OnExitWith(s, h) hooks that see the payload of every event entering or leaving s
OnTransition(h) a hook on every transition but an internal one
OnEnterGroup(g, h) / OnExitGroup(g, h) hooks on the transitions that cross g's boundary; …With variants see the payload
NewGroup(name, a, b) a group, usable as a rule on its own
Rules(...) several rules as one value

Guards and actions

Guard and Action are methods on the transition, not extra arguments to To:

fsm.From(recStopped).On(evRecFinish).To(recFinished).
	Guard("all chunks and tracks uploaded", uploadsSettled).
	Action(sealManifest)

Guards run in registration order and the first rejection wins. The description is the static condition — it names the guard in the error and labels the edge in DOT and Mermaid; the returned error is why it failed this time, and GuardError unwraps to it, so errors.Is(err, errUploadPending) works.

An action runs after every guard passed and before the state moves; if it fails, nothing is assigned and no hook runs.

Both receive the payload at its declared type.

Several sources

FromEach declares one transition from each of several states:

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

One edge per source, with nothing relating them. Use a group when the sources are related, or when a state added later should inherit the rule.

Initial state

Initial records where a fresh instance starts — the machine still holds no state. It buys two build-time checks: no unreachable state, and no start with no way out.

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

It is also what DOT and Mermaid draw the start marker from.

Firing

tr, err := recordingFSM.Fire(ctx, &r.state, evRecStop, r) // with a payload
tr, err := recordingFSM.Send(ctx, &r.state, evRecUpload)  // for events declared with Signal

Fire returns the Transition it made: tr.From, tr.To, and tr.Event() for display. Names are not unique, so branch with tr.Is(evRecStop), never on the string.

A caller that fires whenever an event might apply — a controller reconciling on every turn — wants a refusal to be no news:

tr, fired, err := m.TryFire(ctx, &st, evReconcile, x) // fired is false, err nil, when nothing applies

TryFire (and TrySend) report a state that does not take the event, or a guard that rejects it, as false with a nil error, and allocate nothing doing so. A failed action, or a state written by a callback, is still an error.

To ask without firing:

err := m.Check(ctx, from, ev, payload) // nil, or the same error Fire would return
ok := m.Can(ctx, from, ev, payload)    // Check with the reason discarded
to, ok := m.To(from, ev)               // the target, ignoring guards

Check evaluates guards but runs no action and no hook.

A machine is immutable once New returns, so one value serves every goroutine that owns a state, with no lock. Hooks are shared, so whatever they touch is yours to synchronize.

Errors

Every fire-time failure is a typed error carrying the edge. Match with errors.AsType:

Error Means State
*NoTransitionError[S] this state does not accept this event untouched
*GuardError[S] a guard rejected; unwraps to the guard's error untouched
*ActionError[S] the action failed; unwraps to the action's error untouched
*StateChangedError[S] a guard or action wrote the state through an aliased payload whatever the callback left

A nil state pointer or the zero Event is a plain error — a caller bug, not a machine event.

StateChangedError catches the aliasing trap: when the payload is the struct holding the state, a guard or action that writes r.state itself would leave you running from a state the machine never assigned. It is reported ahead of any error the callback also returned, which is kept in Err but deliberately not unwrapped — a guard's sentinel means "state untouched, retry", and matching it here would re-fire from the wrong source. Read it with errors.AsType.

Hooks

Hooks run bookkeeping around the assignment. They return nothing: everything that can fail does so before the state moves, so a hook never runs for a transition that did not happen.

fsm.OnEnter(recStopped, func(_ context.Context, tr fsm.Transition[recState]) { ... })
fsm.OnExit(recActive, func(_ context.Context, tr fsm.Transition[recState]) { ... })

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

The order inside Fire is fixed:

lookup → guards → action → the check that neither wrote the state → OnExit(from) → assignment → OnTransition → OnEnter(to)

OnTransition precedes the entry hooks so an entry hook that fires again is logged after its cause; the price is that a transition hook must observe and not fire. A self-transition From(a).On(ev).To(a) is UML's external kind — it runs exit then entry.

Internal transitions

An event a state handles without leaving it — a heartbeat that refreshes a deadline, a vote that is counted — is declared with Stay, UML's internal transition:

fsm.From(follower).On(evHeartbeat).Stay().Action(recordLeader)

Guards and actions run as on any transition, and Fire returns a transition from the state to itself; the state is not left, so no hook runs, OnTransition included. Keeping such events in the table is what lets it say what each state accepts: a NoTransitionError still means the state refuses the event, Check and Can answer for it, and DOT draws it as a dashed loop, Mermaid as a line inside the state. It is not a way out, so a state with only internal transitions is still in Terminals().

Typed hooks

A plain Hook sees the transition but not the payload, because a state can be entered by events carrying different types. OnEnterVia and OnExitVia name the event, which fixes the type:

fsm.OnExitVia(recActive, evRecStop, func(_ context.Context, tr fsm.Transition[recState], r *recording) {
	log.Info("stopping", "pending", r.pendingUploads)
})

OnEnterWith and OnExitWith do the same for every event entering or leaving the state, which is what a hook that must not miss a way in needs — a counter labelled by the payload, typically:

fsm.OnEnterWith(recStopped, func(_ context.Context, tr fsm.Transition[recState], r *recording) {
	log.Info("stopped", "on", tr.Event(), "pending", r.pendingUploads)
})

They read the finished transition table, group-inherited edges included, so an event added later is covered without another declaration. That requires every such event to carry the hook's payload type; New reports one that does not, rather than let the hook silently miss it.

New rejects a typed hook that no transition could trigger, so a terminal state takes OnEnterWith without OnExitWith. Plain hooks run first, then Via hooks, then With hooks.

A machine whose hooks all need the aggregate — a controller, an elector — tends to give every event one payload type: a small struct holding the aggregate and whatever the event adds. That is what lets OnEnterWith serve every state, at the price of per-event typing; name events with distinct payloads where that typing matters more.

Hooks that fire the machine

An entry hook may fire the machine again, on the state it just entered: a start that fails can move straight back out. A package-level machine whose hook names it would be a variable initialized with itself, which Go rejects as an initialization cycle. Reach the machine through the payload instead — the aggregate holds the machine it runs on — and the variable stays a plain declaration:

type job struct {
	phase   phase
	machine *fsm.Machine[phase] // the machine it runs on, for hooks that fire it
}

var lifecycle = fsm.MustNew("job",
	fsm.From(idle).On(evStart).To(starting),
	fsm.From(starting).On(evStartFailed).To(idle),
	fsm.OnEnterVia(starting, evStart, func(ctx context.Context, _ fsm.Transition[phase], j *job) {
		if err := j.launch(); err != nil {
			_, _ = j.machine.Fire(ctx, &j.phase, evStartFailed, j) // through the job, not by name
		}
	}),
)

Building the machine in init also compiles, but only hides the cycle from the compiler.

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

func (s pcpState) String() string {
	return [...]string{"connected", "reconnecting", "deleted", "abandoned"}[s]
}

// A disconnect carries why it happened, so the reason cannot be lost between
// the caller and the guard that reads it.
type disconnect struct {
	intentional bool
	reason      string
}

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", func(_ context.Context, d disconnect) error {
			if d.intentional {
				return fmt.Errorf("left on purpose: %s", d.reason)
			}
			return nil
		}),
	fsm.From(pcpReconnecting).On(evPcpReconnect).To(pcpConnected),

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

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 others keep the inherited one:

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

That override is what FromEach cannot do, and the reason to reach for a group.

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 pays nothing for it. Each row records 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)
	}
}
// connected --kick--> deleted inherited from "live"
// reconnecting --kick--> deleted inherited from "live"

New rejects an unknown member, a repeated one, a name reused for different members, two groups claiming one event for the same state, and a group transition every member overrides.

Group hooks

A group has entry and exit hooks, which run as a superstate's do: only on a transition that crosses its boundary.

fsm.OnEnterGroup(pcpLive, func(_ context.Context, tr fsm.Transition[pcpState]) { live.Inc() })
fsm.OnExitGroup(pcpLive, func(_ context.Context, tr fsm.Transition[pcpState]) { live.Dec() })

A move between members — connected to reconnecting and back — runs neither, so the count never dips. OnEnterGroupWith and OnExitGroupWith see the payload, which every event crossing the boundary must then carry. A transition leaving several groups leaves the smaller one first, and one entering several enters the larger one first, as a substate is left before its superstate and entered after it. The exit hooks of a group run after the state's own, and its entry hooks before the state's own.

Groups cover most of what substates are for, but not all: no nesting as states, no initial member. Why, in docs/DESIGN.md.

Introspection

States, Events, Edges, Groups, Terminals, Unreachable and Initial report the machine's shape in declaration order, so the output is stable and worth asserting on:

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 is somewhere a value gets stuck; an unreachable state usually means a missing transition. Declaring Initial turns the second check into a build error.

DOT and Mermaid draw the machine.

Edges returns the table, guard descriptions included:

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() and Transition.Event() are names, for display; identify a trigger with e.Is(evRecStop).

DOT

DOT renders a Graphviz digraph: a dot points at the initial state, terminals are double circles, guards appear in edge labels, and a group becomes a cluster with inherited transitions drawn once from the boundary.

go run ./yourcmd | dot -Tsvg -o machine.svg
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"];
}

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 inside another is drawn inside its cluster. A group that partly overlaps an earlier one cannot be drawn as a cluster without boxing the wrong states, so it gets none, and a comment in the output says so. A boundary arrow is drawn only when it would be accurate — per-member arrows are used when a member overrides the event, when the group has no cluster, or when the target is itself a member. Output follows declaration order, so it can be committed next to the code and diffed.

Mermaid

Mermaid renders the same diagram as a Mermaid state diagram, for a mermaid block in Markdown, which GitHub draws in place. Groups are composite states, nested, left out and drawn from as DOT draws its clusters; an internal transition is a line inside its state, below the name; [*] points at the initial state, and a terminal points at [*]. This is participantFSM from Groups:

---
title: "participant"
---
stateDiagram-v2
    direction LR
    state "live" as g0 {
        s0
        s1
    }
    s0 : connected
    s1 : reconnecting
    s2 : deleted
    [*] --> s0
    s0 --> s1 : disconnect<br>[disconnect was not intentional]
    s1 --> s0 : reconnect
    g0 --> s2 : kick
    s2 --> [*]
Loading

A block in a README goes stale as the machine changes; a test that compares it with Mermaid() keeps it honest. States and groups are numbered by declaration order (s0, g0) and labelled with their names, so any name is safe: Mermaid's syntax characters are written as entity codes.

Performance

Guards and actions are combined at construction, where the payload type is still 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. Hooks are worked out at construction too: every row gets a plan, the hooks it runs before the assignment and after it, so a machine with hooks does one lookup per fire, and one without skips even that on a flag.

goos: darwin, goarch: arm64, cpu: Apple M3 Max
BenchmarkFire-14             33.00 ns/op    0 B/op    0 allocs/op
BenchmarkFireWithHooks-14    43.69 ns/op    0 B/op    0 allocs/op

One iteration is two fires, one with an action, so a single Fire is about 16 ns. BenchmarkFireWithHooks is the same round trip with an entry hook, an exit hook and both payload hooks.

Against other libraries

The same a → b → a cycle, built once and fired in a loop; one iteration is two transitions. Source in benchmarks/, a separate module so the root stays dependency-free.

plain guard + action payload
floatdrop/fsm 26.1 ns, 0 allocs 49.6 ns, 0 allocs 35.6 ns, 0 allocs
qmuntal/stateless v1.8.0 188 ns, 0 allocs 269 ns, 6 allocs 267 ns, 6 allocs
looplab/fsm v1.0.4 405 ns, 10 allocs 562 ns, 10 allocs 545 ns, 12 allocs

These are different designs, not a ranking:

  • They hold the state. A looplab.FSM or stateless.StateMachine owns a current state, so you need one per entity and the fire path takes a lock. A fsm.Machine is configuration shared by every entity — most of the gap, and also why it has no State().
  • They are any-typed. States, triggers and payloads are any, which is where the allocations come from; it also makes a misspelled trigger a runtime error rather than a compile error.
  • They do more. stateless has hierarchical substates, dynamic destinations and a trigger queue; looplab has async transitions and metadata. If you need those, this is the price of a feature, not overhead.

Re-measure with -count=6 before quoting a number; single runs vary ~10%.

Design

The reasoning behind the API — state ownership, typed payloads, Rule as the single option type, guard semantics, why groups are a build-time expansion, and the known limitations — is in docs/DESIGN.md.

License

MIT, see LICENSE.

About

A small finite state machine for Go 1.27+

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages