Documentation
¶
Overview ¶
Package statemachine — ranges 0.2.56.* (ADR 0120 core/app/statemachine block) and 0.3.88.* (ADR 0120 service/app/statemachine block, declared here since ADR 0160).
Package statemachine — declares the sentinel *errs.Error outcomes of the domain: the contract's one refusal, and the engine's outcomes. Each var's name equals its errs.Define Reason in SCREAMING_SNAKE form.
No Public text names a key, an event or a state: those travel as log-only fields, because a key is often an identifier a caller would rather not see echoed, and the fields are where an operator looks anyway.
Package statemachine — hosts the values the Journal port speaks: a record of one entity and the steps of its history.
Package statemachine declares the ports of the state-machine domain (ADR 0120): the Store a machine reads and writes the entities it drives through, the Journal that keeps beside them what a store does not — when each entity entered its state, and the transitions that brought it there — and the values those two ports speak (RecordValue, StepValue, Trigger). The engine — declarations, transitions, hooks, the agenda of timers and the loop that fires them — lives in internal/service/app/statemachine; this package owns only the contract.
The store stays the single source of truth ¶
An entity's state is a field of the entity, and the entity lives in the caller's store, not in the machine. A machine never keeps a second copy of an entity: it reads it through Store.Get, writes it through Store.Insert or Store.Replace, and forgets it the moment the caller says it was deleted. What the machine does keep — the instant an entity entered its state and a bounded history — is not something the entity carries, and it lives in a Journal so a restart does not reset every timer.
Missing and existing are answers, not errors ¶
Store.Get reports an absent entity with found == false, Store.Insert a taken key with inserted == false, and Store.Replace a vanished entity with replaced == false. A store therefore never has to produce a code of this domain to say something ordinary, and the machine decides what each answer means: a transition refused, an entity that does not exist, a creation that collided. An error from a port method is a store that FAILED, nothing else.
There is no registry ¶
A store is a caller's collection and a journal is where that caller keeps its bookkeeping: neither has a name the SDK could resolve, so, like lock (ADR 0052) and secret (ADR 0096), the domain has no name->implementation table.
Package statemachine — hosts Trigger, the closed set of things that fire a transition, with its text form. Its one refusal, TriggerUnknown, is declared in errors.go with the domain's other sentinels.
Index ¶
Constants ¶
const CodeDelayInvalid errs.Code = 0x00_03_58_11 // 0.3.88.17
CodeDelayInvalid identifies a timer transition declared with a duration that is not positive.
const CodeEntityExists errs.Code = 0x00_03_58_03 // 0.3.88.3
CodeEntityExists identifies a Start whose entity's key the store already holds.
const CodeEntityMissing errs.Code = 0x00_03_58_02 // 0.3.88.2
CodeEntityMissing identifies a key the store holds no entity under — never there, or deleted while a transition ran.
const CodeEventInvalid errs.Code = 0x00_03_58_0F // 0.3.88.15
CodeEventInvalid identifies a transition declared with an empty event name, or with the name reserved for a creation.
const CodeFunctionMissing errs.Code = 0x00_03_58_12 // 0.3.88.18
CodeFunctionMissing identifies a nil function where a definition needs one: the state accessor, a guard, an instant function, a hook — or a nil definition.
const CodeFunctionPanicked errs.Code = 0x00_03_58_0D // 0.3.88.13
CodeFunctionPanicked identifies a guard or an instant function that panicked while the loop asked it whether a transition was due.
const CodeHookChangedKey errs.Code = 0x00_03_58_08 // 0.3.88.8
CodeHookChangedKey identifies an OnEnter hook that changed the key of the entity it was entering.
const CodeHookChangedState errs.Code = 0x00_03_58_07 // 0.3.88.7
CodeHookChangedState identifies an OnEnter hook that changed the state of the entity it was entering.
const CodeHookFailed errs.Code = 0x00_03_58_05 // 0.3.88.5
CodeHookFailed identifies a hook that returned an error: an OnEnter hook's error fails its transition, an OnTransition hook's is reported.
const CodeHookPanicked errs.Code = 0x00_03_58_06 // 0.3.88.6
CodeHookPanicked identifies a hook that panicked, recovered as an error.
const CodeInitialMissing errs.Code = 0x00_03_58_0E // 0.3.88.14
CodeInitialMissing identifies a definition with no initial state.
const CodeJournalFailed errs.Code = 0x00_03_58_0B // 0.3.88.11
CodeJournalFailed identifies a Journal method that returned an error.
const CodeKeyEmpty errs.Code = 0x00_03_58_04 // 0.3.88.4
CodeKeyEmpty identifies an entity handed to Start whose key is empty.
const CodeLoopPanicked errs.Code = 0x00_03_58_15 // 0.3.88.21
CodeLoopPanicked identifies a panic the loop recovered from the caller's own code while looking at an entity: a store method, a journal, an observer.
const CodeLoopRunning errs.Code = 0x00_03_58_0C // 0.3.88.12
CodeLoopRunning identifies a Run or a Step asked for while another is going.
const CodeReentrant errs.Code = 0x00_03_58_09 // 0.3.88.9
CodeReentrant identifies a transition asked for, through the context an OnEnter hook was given, on the machine running that hook.
const CodeStoreFailed errs.Code = 0x00_03_58_0A // 0.3.88.10
CodeStoreFailed identifies a Store method that returned an error.
const CodeStoreMissing errs.Code = 0x00_03_58_13 // 0.3.88.19
CodeStoreMissing identifies a configuration with no store.
const CodeTransitionDuplicate errs.Code = 0x00_03_58_10 // 0.3.88.16
CodeTransitionDuplicate identifies a second transition declared for one event from one state.
const CodeTransitionRefused errs.Code = 0x00_03_58_01 // 0.3.88.1
CodeTransitionRefused identifies an event no declared transition takes from the entity's current state.
const CodeTriggerUnknown errs.Code = 0x00_02_38_01 // 0.2.56.1
CodeTriggerUnknown identifies a name ParseTrigger does not know.
const CodeWaitAbandoned errs.Code = 0x00_03_58_14 // 0.3.88.20
CodeWaitAbandoned identifies a caller whose context ended while it waited for another transition of the same entity to finish.
const CreateEvent string = "create"
CreateEvent is the event name of the step that brings a new entity into its initial state. No declared transition may take it, so a history can always tell a creation from an event.
Variables ¶
var ( // TriggerUnknown refuses a name that is not one of the five triggers: a // journal written by something else, or damaged. The refused name is never // repeated — a journal is the caller's data. TriggerUnknown = errs.Static(CodeTriggerUnknown, "TRIGGER_UNKNOWN", "That is not a state-machine trigger", "core/app/statemachine: a trigger is start, event, delay, deadline or guard; the name given is none of them", 0, 0) // TransitionRefused is returned by Fire for an event no declared // transition takes from the entity's current state — including an event // that exists but leaves another state, and a timer or guard transition, // which only the machine's own loop fires. The entity is returned beside // it, so the caller can read the state that refused. TransitionRefused = errs.Static(CodeTransitionRefused, "TRANSITION_REFUSED", "That transition is not possible from the current state", "service/app/statemachine: no event transition by that name leaves the entity's state; the fields name the event and the state", 409, 0) // EntityMissing is returned when the store holds no entity under the key: // by Fire for a key never stored, and by any transition whose entity was // deleted while it ran — the store's Replace reported it gone, and the // transition stored nothing rather than bring it back. EntityMissing = errs.Static(CodeEntityMissing, "ENTITY_MISSING", "No such entity", "service/app/statemachine: the store holds no entity under the key, or it was deleted while the transition ran; the key field names it", 404, 0) // EntityExists is returned by Start when the store already holds an // entity under the new one's key. Nothing was stored and no hook ran // after the refusal. EntityExists = errs.Static(CodeEntityExists, "ENTITY_EXISTS", "An entity with that key already exists", "service/app/statemachine: the store's Insert reported the key taken; the key field names it", 409, 0) // KeyEmpty is returned by Start for an entity whose key is empty. The // engine locks, records and schedules by key, so an empty one would make // every such entity the same entity. KeyEmpty = errs.Static(CodeKeyEmpty, "KEY_EMPTY", "The entity has no key", "service/app/statemachine: Store.Key returned an empty key for the entity handed to Start", 0, 0) // HookFailed accompanies the error a hook returned, joined with it so // both errors.Is and errs.HasCode answer: an OnEnter hook's failure fails // its transition and is returned; an OnTransition hook's is reported // through Config.Report and the transition stands. HookFailed = errs.Static(CodeHookFailed, "HOOK_FAILED", "A state-machine hook failed", "service/app/statemachine: a hook returned an error, joined beside this one; the fields name the hook, the event and the state", 0, 0) // HookPanicked is a hook that panicked. The panic is recovered where the // hook ran, so an OnEnter hook fails its transition — and the entity's // lock is released on the way out, which is what kept a panicking hook // from leaving the machine locked for good. The recovered value and the // stack travel as log-only fields. HookPanicked = errs.Static(CodeHookPanicked, "HOOK_PANICKED", "A state-machine hook panicked", "service/app/statemachine: a hook panicked and was recovered; the fields carry the hook, the event, the value and the stack", 0, 0) // HookChangedState refuses a transition whose OnEnter hook changed the // entity's state. A hook may change the entity, not where it is going: // the history would record one state and the store hold another. HookChangedState = errs.Static(CodeHookChangedState, "HOOK_CHANGED_STATE", "A state-machine hook changed the state it was entering", "service/app/statemachine: after the OnEnter hooks the entity's state was no longer the transition's target; nothing was stored", 0, 0) // HookChangedKey refuses a transition whose OnEnter hook changed the // entity's key: storing it would replace, or create, another entity. HookChangedKey = errs.Static(CodeHookChangedKey, "HOOK_CHANGED_KEY", "A state-machine hook changed the key of its entity", "service/app/statemachine: after the OnEnter hooks Store.Key no longer returned the entity's key; nothing was stored", 0, 0) // Reentrant refuses a Start or a Fire made, through the context an // OnEnter hook was given, on the machine running that hook. The hook runs // while the machine holds its entity's lock, so the call would wait for // itself; it is refused instead of deadlocking. Fire from an // OnTransition hook, which runs once the lock is released. Reentrant = errs.Static(CodeReentrant, "REENTRANT", "A state-machine hook cannot fire its own machine", "service/app/statemachine: Start or Fire was called with a context an OnEnter hook of the same machine was given; fire from OnTransition instead", 0, 0) // StoreFailed wraps an error a Store method returned. When the store's // error is itself an SDK error its code is kept and this one joins the // trail, so both errs.HasCode checks answer. StoreFailed = errs.Static(CodeStoreFailed, "STORE_FAILED", "The state machine's store failed", "service/app/statemachine: a Store method returned an error; the fields name the operation", 0, 0) // JournalFailed wraps an error a Journal method returned. Load failing // fails the machine's opening; Save and Delete failing are reported and // the transition stands, since the store is the source of truth and a // lost record only costs a timer its origin after a restart. JournalFailed = errs.Static(CodeJournalFailed, "JOURNAL_FAILED", "The state machine's journal failed", "service/app/statemachine: a Journal method returned an error; the fields name the operation", 0, 0) // LoopRunning refuses a Run or a Step while another Run or Step of the // same machine is going: one loop at a time, so an entity takes at most // one automatic transition per run. LoopRunning = errs.Static(CodeLoopRunning, "LOOP_RUNNING", "The state machine's loop is already running", "service/app/statemachine: Run or Step was called while another Run or Step of this machine had not returned", 0, 0) // FunctionPanicked is a guard or an instant function that panicked while // the loop asked whether a transition was due. The entity is retried // after a backoff; the value and the stack travel as log-only fields. FunctionPanicked = errs.Static(CodeFunctionPanicked, "FUNCTION_PANICKED", "A state-machine guard or deadline function panicked", "service/app/statemachine: a guard or an instant function panicked and was recovered; the fields carry the event, the value and the stack", 0, 0) // InitialMissing refuses a definition that never called Initial: Start // would have no state to put a new entity in. InitialMissing = errs.Static(CodeInitialMissing, "INITIAL_MISSING", "The state machine has no initial state", "service/app/statemachine: the definition never called Initial", 0, 0) // EventInvalid refuses a transition declared with an empty event name or // with the name a creation's history step takes. EventInvalid = errs.Static(CodeEventInvalid, "EVENT_INVALID", "A transition needs a name, other than the reserved one", "service/app/statemachine: a transition was declared with an empty event name or with core/app/statemachine.CreateEvent", 0, 0) // TransitionDuplicate refuses a second transition for one event from one // state: Fire, and the loop's first-declared rule, would have two answers. TransitionDuplicate = errs.Static(CodeTransitionDuplicate, "TRANSITION_DUPLICATE", "That event already leaves that state", "service/app/statemachine: a transition was declared twice for one event from one state; the fields name them", 0, 0) // DelayInvalid refuses an After with a duration that is not positive: a // timer due the instant its state is entered is a guard that always // holds, and reads as a mistake. DelayInvalid = errs.Static(CodeDelayInvalid, "DELAY_INVALID", "A timer transition needs a positive duration", "service/app/statemachine: After was declared with a duration of zero or less; the event field names it", 0, 0) // FunctionMissing refuses a nil function a definition needs: the state // accessor, a guard, an instant function or a hook — and a nil // definition handed to NewStateMachine. FunctionMissing = errs.Static(CodeFunctionMissing, "FUNCTION_MISSING", "The state machine was given no function where it needs one", "service/app/statemachine: a nil state accessor, guard, instant function, hook or definition; the fields say which", 0, 0) // StoreMissing refuses a configuration with no store: a machine's // entities have to live somewhere. StoreMissing = errs.Static(CodeStoreMissing, "STORE_MISSING", "The state machine has no store", "service/app/statemachine: Config.Store is nil", 0, 0) // LoopPanicked is a panic the loop recovered from the caller's own code — // a store method, a journal, an observer — while it looked at one // entity. When the panic interrupted the entity's pass, the entity is // retried after its backoff; when it came from the observer's end, the // transition was stored already and stands — call=observe-end says so. // Either way the loop goes on. Start and Fire, which run on the caller's // goroutine, let such a panic through, with the entity's lock released. // The value and the stack travel as log-only fields. LoopPanicked = errs.Static(CodeLoopPanicked, "LOOP_PANICKED", "The state machine's loop recovered a panic", "service/app/statemachine: a store, journal or observer call panicked in the loop; the fields carry the key, the value, the stack and, for the observer's end, call", 0, 0) // WaitAbandoned is returned when the caller's context ended while it // waited for another transition of the same entity to finish. Nothing was // read or stored. WaitAbandoned = errs.Static(CodeWaitAbandoned, "WAIT_ABANDONED", "The request ended while the entity was busy", "service/app/statemachine: the context ended before the entity's lock was free; the cause is the context's error", 503, 0) )
Functions ¶
This section is empty.
Types ¶
type Journal ¶
type Journal[S comparable] interface { // Load returns every record the journal holds. The engine calls it once, // when a machine opens. Load(ctx context.Context) (records []RecordValue[S], err error) // Save stores each record, replacing any record of the same key. Save(ctx context.Context, records ...RecordValue[S]) error // Delete forgets the records of keys; a key it does not hold is not an // error. Delete(ctx context.Context, keys ...string) error }
Journal keeps, beside the store, what a machine knows about each entity and the entity does not carry: the instant it entered its state and the transitions that brought it there. Without one the machine keeps it in memory, and after a restart every entity re-enters its state at the moment the machine opens — which resets every After timer.
Implementations MUST be safe for concurrent use. The engine calls them with no lock of its bookkeeping held, one call at a time PER KEY: two writes of one key arrive in the order the machine made them, and writes of different keys may arrive at once — an implementation that rewrites one file orders those itself. A journal may read the machine, its census or its records; it must not tell it anything — Changed, Deleted, Start or Fire from inside a journal call waits for itself.
Save and Delete take several records at once because a machine that opens reconciles every entity in one pass; a journal that rewrites a whole file then does it once rather than once per entity.
The method set is FROZEN at three (ADR 0039).
type RecordValue ¶
type RecordValue[S comparable] struct { // Key is the entity's key in the store. Key string `json:"key"` // State is the state the entity is in. State S `json:"state"` // Entered is when the entity entered State. Entered time.Time `json:"entered"` // History holds the latest transitions, oldest first. History []StepValue[S] `json:"history,omitempty"` }
RecordValue is what a machine keeps about one entity beside the store: the state it is in, since when, and the transitions that brought it there, oldest first and bounded by the machine's history length.
State and Entered describe the entity as the machine last saw it. When an entity's state changes behind the machine's back — a write that did not go through a transition — the machine records the new state with Entered set to the moment it learned of it, and adds no step: it knows the state, not a transition.
type StepValue ¶
type StepValue[S comparable] struct { // Event is the transition's name; [CreateEvent] for a creation. Event string `json:"event"` // From is the state left: the zero S for a creation. From S `json:"from"` // To is the state entered. To S `json:"to"` // Trigger says what fired the transition. Trigger Trigger `json:"trigger"` // Actor says who fired it when a caller did, as the machine's // configuration reads it from the caller's context; empty when the // machine's own loop fired it or nobody said. Actor string `json:"actor,omitempty"` // At is when the transition was stored. At time.Time `json:"at"` }
StepValue is one transition in a record's history: which, from where to where, what fired it, who asked, and when it was stored.
type Store ¶
type Store[E any] interface { // Key returns the key entity is stored under. It must be a pure function // of the entity: the engine locks, records and schedules by it, and a // transition never changes it. Key(entity E) (key string) // Get returns the entity stored under key, and found == false — with a // nil error — when there is none. Get(ctx context.Context, key string) (entity E, found bool, err error) // Insert stores entity under a key no entity holds. When one does, it // stores nothing and reports inserted == false with a nil error. Insert(ctx context.Context, entity E) (inserted bool, err error) // Replace stores entity over the entity holding its key. When none does // — it was deleted while a transition ran — it stores nothing and reports // replaced == false with a nil error: a transition never brings back an // entity somebody deleted. Replace(ctx context.Context, entity E) (replaced bool, err error) // All yields every entity, then stops; an error ends the sequence. The // engine ranges over it once, when a machine opens, to reconcile its // journal and build its agenda. All(ctx context.Context) iter.Seq2[E, error] }
Store is the port through which a machine reads and writes the entities whose state it drives. The caller implements it over its own collection — a document store, a table, a map — and hands it to the engine.
Implementations MUST be safe for concurrent use: the engine calls them from the goroutine firing a transition and from its own loop at once.
The method set is FROZEN at five (ADR 0039): pkg/v1/app/statemachine aliases this interface, so a sixth method would break every downstream implementation at compile time. A new capability arrives as a sibling interface reached by type assertion.
type Trigger ¶
type Trigger uint8
Trigger says what fired a transition. The set is closed: every transition a machine can take is declared one of these ways, and a history names which.
The values are STABLE — a journal may store them as numbers, and none is ever renumbered — and the zero value is not a trigger, so a step read from a journal that lost the field names no trigger rather than some default. String and ParseTrigger give the text form.
const ( // TriggerStart is the creation of an entity: the step into its initial // state. TriggerStart Trigger = iota + 1 // TriggerEvent is an event transition, fired by a caller. TriggerEvent // TriggerDelay is a timer transition due once an entity has spent a // declared duration in its state. TriggerDelay // TriggerDeadline is a timer transition due at an instant the entity // itself carries — a due date, an expiry. TriggerDeadline // TriggerGuard is a transition due as soon as a condition on the entity // holds. TriggerGuard )
func ParseTrigger ¶
ParseTrigger reads a trigger's name, as String writes it. Anything else — an empty name included — is refused with TriggerUnknown, which does not repeat the name.