Skip to content

feat: share compiled transition tables between FSMs via Spec - #116

Open
tigerquoll wants to merge 1 commit into
looplab:mainfrom
tigerquoll:feat/shared-spec
Open

feat: share compiled transition tables between FSMs via Spec#116
tigerquoll wants to merge 1 commit into
looplab:mainfrom
tigerquoll:feat/shared-spec

Conversation

@tigerquoll

@tigerquoll tigerquoll commented Aug 16, 2026

Copy link
Copy Markdown

Motivation

Applications that keep one FSM per managed object pay for NewFSM recompiling the identical transition and callback tables into per-instance maps every time. In a real system running one machine per Kubernetes pod (100k+ concurrent instances), that is ~5.2KB / 20 allocations per instance, and fsm.NewFSM accounted for 8.7% of the process's total allocation volume. The tables are immutable after construction, so they can be compiled once and shared. Related asks in the issue list circle the same cost from other directions (#109 instance reusability, #40 recreating full definitions on restore).

Change

Two new constructors split compilation from instantiation; nothing else changes:

spec := fsm.NewSpec(events, callbacks)      // compile the tables once
m := fsm.NewFSMFromSpec("closed", spec)     // per-object: state + locks only
  • NewSpec(events, callbacks) *Spec — the compilation logic moved verbatim out of NewFSM (transition table + the full callback-name resolution including shorthand forms). Spec is opaque and immutable; any number of FSMs can share one, concurrently.
  • NewFSMFromSpec(initial, spec) *FSM — a fully functional FSM referencing the shared tables. Everything mutable stays per-instance: current state, metadata (now lazily allocated on first SetMetadata), mutexes, transitioner, in-flight transition.
  • NewFSM(initial, events, callbacks) is now exactly NewFSMFromSpec(initial, NewSpec(events, callbacks)).
  • All internal reads go through nil-receiver-safe Spec accessors, so a zero-value fsm.FSM (and a machine built from a nil Spec) keeps today's graceful behavior — Can=false, UnknownEventError, empty transitions/graphs — instead of panicking. The zero-value contract is now stated on the FSM doc comment.

Included fix: data race in the visualizers

Visualize and both mermaid visualizers read fsm.current without the state lock — a pre-existing data race with Event/SetState, reproducible under -race on main. Since this change touches those functions and encourages concurrent use of many machines, they now read via Current(). A race regression test is included that fails on the unfixed code.

Behaviour preservation and tests

The existing test suite passes byte-unmodified — that is the primary evidence, since NewFSM now runs the shared path. The new tests add coverage of NewFSMFromSpec across the full feature matrix, plus a guard against the constructors diverging in the future:

  • every (state, event) pair of a 4-state × 7-event machine, including unknown events
  • callbacks of all four phases, generic and shorthand forms, callback args, callbacks setting e.Err
  • e.Cancel() with and without an error; async transitions via e.Async() + Transition(); InTransitionError / NotInTransitionError; AsyncError.CancelTransition; context cancellation mid-transition
  • Visualize / VisualizeWithType / both mermaid graph types: byte-identical output
  • per-instance independence of state, SetState, and metadata across FSMs sharing one Spec
  • zero-value and nil-Spec behavior pinned; 50 FSMs sharing one Spec transitioning concurrently under -race

Benchmark

Constructing the 4-state × 7-event machine used by the differential test (Apple M2 Pro, go1.26, -benchmem -benchtime 500000x -count 3, median). The motivation's ~5.2KB / 20 allocations is the same measurement on a larger production machine (10 states, 9 events); the per-instance saving scales with the size of the tables, the shared-spec cost does not:

BenchmarkNewFSM-10            1102 ns/op   1544 B/op    9 allocs/op
BenchmarkNewFSMFromSpec-10    21.5 ns/op    112 B/op    1 allocs/op

Applications that run one FSM per managed object, for example a cluster
scheduler with one machine per pod and 100k+ concurrent instances, pay for
NewFSM recompiling the identical transition and callback tables into
per-instance maps every time. In one such system fsm.NewFSM accounted for 8.7%
of the total allocation volume, about 5.2KB and 20 allocations per instance.
The tables are immutable after construction, so they can be compiled once and
shared.

Spec is the compiled form of a machine description, holding the transition map
and the callbacks resolved from their names. NewSpec compiles it, exactly as
NewFSM did before, and NewFSMFromSpec builds a machine that shares it.
Everything that changes while a machine runs, the current state, the transition
function, the metadata and the mutexes, stays per machine, so one Spec can be
used by any number of machines, also concurrently. NewFSM is now
NewFSMFromSpec(initial, NewSpec(events, callbacks)), the compilation exists in
one place only and no exported API or behavior changes.

The Spec is read through nil safe accessors, so a FSM without a Spec, either
the zero value or one built from a nil Spec, keeps answering as a machine
without transitions instead of panicking. The metadata map is now allocated on
first write instead of in the constructor, which is one allocation less per
machine for the machines that never use it.

This also fixes a pre-existing data race: Visualize and both Mermaid
visualizations read FSM.current directly, without holding stateMu, which races
with Event and SetState. They now read the state once through Current(), which
takes the lock and also keeps the output consistent with itself. The added
test, one goroutine transitioning while another visualizes, reports the race in
all three visualizations under -race before the change.

That the extraction preserved behavior is shown by the pre-existing test suite,
which passes unmodified. On top of that spec_test.go drives a machine built
with NewFSM and one built from a Spec through the same scenarios and compares
callback order, errors and states: all state and event pairs, callbacks of
every phase including the generic and shorthand forms, callback arguments,
errors set by callbacks, cancellation with and without an error, async
transitions completed with Transition, InTransitionError and
NotInTransitionError, canceling an async transition, a canceled context,
AvailableTransitions and byte identical visualizations. Since NewFSM delegates
to the new constructors the two runs share their code path, so this is feature
coverage of NewFSMFromSpec and a guard against the constructors diverging
later, not a second proof of the refactoring. Metadata and state are verified
to be per machine, the zero value and a nil Spec are pinned, and a shared Spec
is exercised by concurrent machines under -race.

Constructing the machine of the differential test, Apple M2 Pro, go1.26:

    BenchmarkNewFSM-10            1102 ns/op    1544 B/op    9 allocs/op
    BenchmarkNewFSMFromSpec-10    21.5 ns/op     112 B/op    1 allocs/op
@tigerquoll

Copy link
Copy Markdown
Author

@maxekman can you review this?

@maxekman

Copy link
Copy Markdown
Member

Thanks for the PR, looks like a really interesting addition. I'll take a look at it.

@tigerquoll

tigerquoll commented Aug 21, 2026

Copy link
Copy Markdown
Author

The CI failure here is a pre-existing flake in TestContextInCallbacks, not something this PR introduces - this PR doesn't touch fsm_test.go, and the same hang reproduces on main in seconds with go test -race -run 'TestContextInCallbacks$' -count=300 -timeout 60s ..

What happens: the test races go func() { cancel() }() against fsm.Event(ctx, "run"). When cancel() fires before the transition function's ctx.Err() check, the transition aborts early - Event still returns context.Canceled, so the error assertion passes, but enter_end never runs, the goroutine that closes enterEndAsyncWorkDone is never spawned, and the test blocks forever at <-enterEndAsyncWorkDone (fsm_test.go:806). The test binary then panics at the 10-minute timeout, failing the whole package. The goroutine dump in the failed run shows exactly that: the test goroutine parked on the channel receive with no goroutine inside the callback. It bites more often on a loaded CI runner than locally, which is why it looks tied to this PR.

I've opened #118 with a deterministic version of the test (cancellation triggered from inside enter_end, so it can't race the context check: 300/300 iterations pass under -race). I've closed/reopened this PR to retrigger CI; the new run is waiting on maintainer approval to execute.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants