fsm

package module
v0.1.0 Latest Latest
Warning

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

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

README

fsm

CI Go Reference License

A small finite state machine for Go, built around three ideas: the caller owns the state, events carry typed payloads, and a machine is a set of rules whose source and target are named separately 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 they never read as states: "stop" and "stopped"
// are one tense apart, which is not a difference worth relying on.
var (
    evRecStop   = fsm.Define[*recording]("stop")
    evRecFinish = fsm.Define[*recording]("finish")
    evRecUpload = fsm.Signal("uploaded")
)

var recordingFSM = fsm.MustNew("recording",
    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.
err := recordingFSM.Fire(ctx, &r.state, evRecStop, r)

Firing an event the current state does not accept is an error, not a panic, and leaves the state untouched:

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

Why the API is shaped this way — state ownership, typed payloads, Rule as the single option type, guard semantics, and the known limitations — is in docs/DESIGN.md.

Introspection

A machine can describe its own shape. States, Edges, Terminals and Unreachable report in declaration order — never map order — so they are stable enough to assert on:

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

An unintended terminal state is a state something can get stuck in, and an unreachable state is a transition someone forgot to wire — both are worth a test rather than a re-read.

Edges returns the transition table itself, including each guard's description:

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
DOT

DOT() renders the machine as a Graphviz digraph, with terminal states drawn as double circles and guards on the edge labels:

fmt.Print(recordingFSM.DOT())
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"];
}

Piped through Graphviz, that is:

active to stopped on stop, stopped to finished on finish guarded by all chunks and tracks uploaded, finished to uploaded

go run ./yourcmd | dot -Tsvg -o machine.svg

Output is deterministic — states and edges are emitted in declaration order, never map order — so the DOT can be committed next to the code and reviewed in a diff when the machine changes.

Benchmark

Fire does not allocate. 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 to that type and calls it with the payload directly, so nothing is boxed.

go test -bench Fire -benchmem -run '^$' ./...
goos: darwin
goarch: arm64
cpu: Apple M3 Pro
BenchmarkFire-12             35204294    33.74 ns/op    0 B/op    0 allocs/op
BenchmarkFireWithHooks-12    17826518    67.18 ns/op    0 B/op    0 allocs/op

One iteration is a round trip of two fires, one of them carrying an action, so a single Fire is roughly 17 ns. FireWithHooks is the same round trip on a machine declaring an entry hook, an exit hook and both payload hooks — that is what hooks cost, and a machine that declares none does not pay it: one flag checked at the top of Fire skips the hook block entirely.

The zero is the part that matters and is pinned by TestFireDoesNotAllocate; it runs without -race, which changes the allocation profile.

Single runs vary by around 10%, so re-measure with -count=6 before quoting a different number.

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.

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
}

// 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.From(pcpConnected).On(evPcpKick).To(pcpDeleted),
	fsm.From(pcpReconnecting).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())

}
Output:
after drop: reconnecting
after reconnect: connected
after kick: deleted
late reconnect: fsm participant: no transition from deleted on reconnect
terminals: [deleted]
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

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 string
	Guard string // guard description, empty when unguarded
}

Edge is a registered transition, reported by Machine.Edges.

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 state 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 (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 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.

Output is deterministic: states and edges 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"];
}

func (*Machine[S]) Edges

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

Edges returns every registered transition, in declaration order.

func (*Machine[S]) Fire

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

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.

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

Send fires an event that carries no payload.

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 state 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 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 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

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

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

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