feat: share compiled transition tables between FSMs via Spec - #116
feat: share compiled transition tables between FSMs via Spec#116tigerquoll wants to merge 1 commit into
Conversation
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
|
@maxekman can you review this? |
|
Thanks for the PR, looks like a really interesting addition. I'll take a look at it. |
|
The CI failure here is a pre-existing flake in What happens: the test races I've opened #118 with a deterministic version of the test (cancellation triggered from inside |
Motivation
Applications that keep one FSM per managed object pay for
NewFSMrecompiling 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, andfsm.NewFSMaccounted 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:
NewSpec(events, callbacks) *Spec— the compilation logic moved verbatim out ofNewFSM(transition table + the full callback-name resolution including shorthand forms).Specis 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 firstSetMetadata), mutexes, transitioner, in-flight transition.NewFSM(initial, events, callbacks)is now exactlyNewFSMFromSpec(initial, NewSpec(events, callbacks)).Specaccessors, so a zero-valuefsm.FSM(and a machine built from a nilSpec) keeps today's graceful behavior —Can=false,UnknownEventError, empty transitions/graphs — instead of panicking. The zero-value contract is now stated on theFSMdoc comment.Included fix: data race in the visualizers
Visualizeand both mermaid visualizers readfsm.currentwithout the state lock — a pre-existing data race withEvent/SetState, reproducible under-raceonmain. Since this change touches those functions and encourages concurrent use of many machines, they now read viaCurrent(). 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
NewFSMnow runs the shared path. The new tests add coverage ofNewFSMFromSpecacross the full feature matrix, plus a guard against the constructors diverging in the future:e.Erre.Cancel()with and without an error; async transitions viae.Async()+Transition();InTransitionError/NotInTransitionError;AsyncError.CancelTransition; context cancellation mid-transitionVisualize/VisualizeWithType/ both mermaid graph types: byte-identical outputSetState, and metadata across FSMs sharing one Spec-raceBenchmark
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: