diff --git a/durable/runner.go b/durable/runner.go index f091b2e..4308c55 100644 --- a/durable/runner.go +++ b/durable/runner.go @@ -246,7 +246,12 @@ func (r *Runner[S, E, C]) Start(ctx context.Context, id InstanceID, input C, opt // (WithClock); the scheduler reads it when arming and ticking timers. buf := make([]state.JournalEntry, 0) recClock := newRecordingClock(r.cfg.clock, &buf) - castOpts := append([]state.CastOption[S]{state.WithClock[S](recClock)}, opts...) + // Force unbounded full-trace history: the runner journals each step's Trace + // (notably the EventPayload) and derives both the step ordinal and the checkpoint + // snapshots from the instance's retained Trace history, so a recover can replay it + // byte-identically. WithUnboundedHistory elevates to full trace and retains every + // step; it is prepended so a caller's opts cannot downgrade it. + castOpts := append([]state.CastOption[S]{state.WithClock[S](recClock), state.WithUnboundedHistory[S]()}, opts...) inst := r.machine.Cast(input, castOpts...) // Persist a baseline checkpoint at baselineStep (below the first fired step) @@ -530,7 +535,7 @@ func (r *Runner[S, E, C]) recover(ctx context.Context, id InstanceID) (*Handle[S recClock := newRecordingClock(r.cfg.clock, &buf) repClock := newReplayClock(clockReadings(tail), recClock) - inst, err := r.machine.Restore(snap, state.WithRestoreClock[S](repClock)) + inst, err := r.machine.Restore(snap, state.WithRestoreClock[S](repClock), state.WithRestoreUnboundedHistory[S]()) if err != nil { return nil, fmt.Errorf("durable: restoring checkpoint for %q: %w", id, err) } diff --git a/durable/runner_test.go b/durable/runner_test.go index bccf486..35d8024 100644 --- a/durable/runner_test.go +++ b/durable/runner_test.go @@ -95,7 +95,10 @@ func parallelMachine() *state.Machine[string, string, *runCtx] { // recovered instance must match byte-for-byte. func liveSnapshotBytes[C any](t *testing.T, m *state.Machine[string, string, C], initial string, entity C, events []string) []byte { t.Helper() - inst := m.Cast(entity, state.WithInitialState(initial)) + // The Runner records in full-trace, unbounded-history mode, so the never-durable + // reference must run in the same mode for a byte-identical snapshot comparison; + // a plain (lite) cast would omit the recorded Trace history. + inst := m.Cast(entity, state.WithInitialState(initial), state.WithUnboundedHistory[string]()) ctx := context.Background() for _, ev := range events { if res := inst.Fire(ctx, ev); res.Err != nil { diff --git a/durable/timetravel.go b/durable/timetravel.go index 3bcb5de..7b876a5 100644 --- a/durable/timetravel.go +++ b/durable/timetravel.go @@ -210,7 +210,10 @@ func restoreBaseline[S comparable, E comparable, C any]( if err != nil { return nil, fmt.Errorf("unmarshaling baseline snapshot: %w", err) } - inst, err := m.Restore(snap, state.WithRestoreClock[S](repClock)) + // Reconstruct in the same full-trace, unbounded-history mode the runner records + // in, so a time-travel reconstruction's Trace history is byte-identical to the + // live run it replays. + inst, err := m.Restore(snap, state.WithRestoreClock[S](repClock), state.WithRestoreUnboundedHistory[S]()) if err != nil { return nil, fmt.Errorf("restoring baseline snapshot: %w", err) } diff --git a/state/README.md b/state/README.md index d6b45d6..e49a445 100644 --- a/state/README.md +++ b/state/README.md @@ -76,6 +76,11 @@ A complete statechart feature surface: event/transition/snapshot/actor stream, and `WaitFor(ctx, inst, predicate)` (plus `WaitInState` / `WaitDone`) that drives an instance until a predicate over its snapshot holds. +- **Opt-in tracing**: a Fire is lite by default (settled result and outcome, + no per-step diagnostic allocation); `WithFullTrace`, `WithInspector`, or the + history options populate the rich per-step Trace. Trace history is opt-in and + bounded — `WithHistory(n)` keeps the last `n` traces in a ring buffer, with + `WithUnboundedHistory` available when every trace is wanted. - **Path enumeration**: `PlanPath` finds the shortest sequence to a target; `state/analysis` adds `ShortestPaths` and `SimplePaths` over the whole graph. diff --git a/state/actor.go b/state/actor.go index 05c9a98..0db9a8c 100644 --- a/state/actor.go +++ b/state/actor.go @@ -235,7 +235,7 @@ func (i *Instance[S, E, C]) actorEffectsOnEntry(entries []S, tr *Trace) []Effect State: fmtState(s), SystemID: inv.SystemID, }) - tr.Microsteps = append(tr.Microsteps, "actor.spawn."+id) + tr.note("actor.spawn." + id) } } return out @@ -260,7 +260,7 @@ func (i *Instance[S, E, C]) actorEffectsOnExit(exits []S, tr *Trace) []Effect { } id := actorInvocationID(m.name, s, ix, inv) out = append(out, StopActor{ID: id}) - tr.Microsteps = append(tr.Microsteps, "actor.stop."+id) + tr.note("actor.stop." + id) } } return out @@ -294,6 +294,19 @@ func NewActor[S comparable, E comparable, C any](inst *Instance[S, E, C], output return a } +// inheritObservability puts the actor's backing child instance into the parent's +// trace mode: full trace, and (for a durable parent) unbounded history retention so +// the actor's own snapshot round-trips its Traces. The ActorSystem calls it (via an +// optional-interface assertion) when its parent runs that way, so a journal/replay +// host that drives the parent — the durable runner — records each actor's rich +// per-step trace and EventPayload too. It is an internal observability gate, not +// part of the ActorInstance contract, so a host's own ActorInstance implementation +// is unaffected. +func (a *actorAdapter[S, E, C]) inheritObservability(full, unbounded bool) { + a.inst.traceFull = full + a.inst.histUnbounded = unbounded +} + // isActorEffect reports whether an effect is one the ActorSystem acts on: a // lifecycle effect (SpawnActor / StopActor) or an actor-communication send effect // (SendTo / SendParent / RespondToSender / ForwardEvent). Other effects (e.g. a diff --git a/state/actor_comms_test.go b/state/actor_comms_test.go index 6350bd3..0cf1e0e 100644 --- a/state/actor_comms_test.go +++ b/state/actor_comms_test.go @@ -297,7 +297,7 @@ func TestComm_IRRoundTrip(t *testing.T) { // And the rehydrated machine actually emits the comm effects when fired. entity := &trec{} - inst := m2.Cast(entity, state.WithInitialState("a")) + inst := m2.Cast(entity, state.WithInitialState("a"), state.WithFullTrace[string]()) res := inst.Fire(context.Background(), "toB") if res.Err != nil { t.Fatalf("Fire err = %v", res.Err) diff --git a/state/actor_snapshot.go b/state/actor_snapshot.go index 12b441e..c04e2e0 100644 --- a/state/actor_snapshot.go +++ b/state/actor_snapshot.go @@ -97,7 +97,17 @@ func (a *actorAdapter[S, E, C]) RestoreJSON(b []byte) error { if err := json.Unmarshal(b, &snap); err != nil { return &SnapshotError{Op: "unmarshal", Reason: "actor child decode failed: " + err.Error()} } - restored, err := a.inst.machine.Restore(snap, WithRestoreClock[S](a.inst.clock)) + restoreOpts := []RestoreOption[S]{WithRestoreClock[S](a.inst.clock)} + // Preserve the actor's observability mode across an in-place restore so a + // journal/replay host keeps recording the child's rich trace, and (under a + // durable parent) keeps retaining its history so the child still round-trips. + switch { + case a.inst.histUnbounded: + restoreOpts = append(restoreOpts, WithRestoreUnboundedHistory[S]()) + case a.inst.traceFull: + restoreOpts = append(restoreOpts, WithRestoreFullTrace[S]()) + } + restored, err := a.inst.machine.Restore(snap, restoreOpts...) if err != nil { return err } @@ -245,6 +255,7 @@ func (s *ActorSystem[S, E, C]) restoreActor(ctx context.Context, snap actorSnaps if err != nil { return &SnapshotError{Op: "restore", State: snap.ID, Reason: "actor re-spawn failed: " + err.Error()} } + s.propagateTrace(inst) ra := &runningActor[E]{ inst: inst, diff --git a/state/actor_system.go b/state/actor_system.go index b8cb9ec..1c39233 100644 --- a/state/actor_system.go +++ b/state/actor_system.go @@ -287,6 +287,22 @@ func (s *ActorSystem[S, E, C]) absorb(ctx context.Context, effects []Effect, cur // spawn creates and registers a running actor from a SpawnActor effect. On an // unbound Src it fires the parent's onError (when usable) so completion still // routes. +// propagateTrace puts a freshly spawned or restored child actor into full trace +// mode when the parent instance runs in full mode, so the parent's observability +// choice (notably a journal/replay host such as the durable runner) reaches the +// whole actor subtree. It uses an optional-interface assertion so a host's own +// ActorInstance implementation is left untouched. +func (s *ActorSystem[S, E, C]) propagateTrace(inst ActorInstance) { + if s.parent == nil || !s.parent.traceFull { + return + } + if ft, ok := inst.(interface { + inheritObservability(full, unbounded bool) + }); ok { + ft.inheritObservability(s.parent.traceFull, s.parent.histUnbounded) + } +} + func (s *ActorSystem[S, E, C]) spawn(ctx context.Context, e SpawnActor) { s.mu.Lock() behavior, ok := s.palette[e.Src.Name] @@ -305,6 +321,7 @@ func (s *ActorSystem[S, E, C]) spawn(ctx context.Context, e SpawnActor) { s.routeError(ctx, e, err) return } + s.propagateTrace(inst) ra := &runningActor[E]{ inst: inst, diff --git a/state/bench_test.go b/state/bench_test.go index 9268f41..6396ad5 100644 --- a/state/bench_test.go +++ b/state/bench_test.go @@ -59,6 +59,61 @@ func BenchmarkParallelBroadcast(b *testing.B) { } } +// benchToggleState is the state type for the steady-state toggle benchmark. +type benchToggleState int + +const ( + benchOn benchToggleState = iota + benchOff benchToggleState = iota +) + +// benchToggleEvent is the event type for the steady-state toggle benchmark. +type benchToggleEvent int + +const benchFlip benchToggleEvent = 0 + +// buildBenchToggleMachine builds a minimal 2-state machine used by +// BenchmarkFireSteady to drive a pure steady-state toggle without Cast overhead. +func buildBenchToggleMachine() *state.Machine[benchToggleState, benchToggleEvent, any] { + return state.Forge[benchToggleState, benchToggleEvent, any]("bench-toggle"). + State(benchOn). + Transition(benchOn).On(benchFlip).GoTo(benchOff). + State(benchOff). + Transition(benchOff).On(benchFlip).GoTo(benchOn). + Initial(benchOn). + Quench() +} + +// BenchmarkFireSteady measures the steady-state Fire cost: Cast once outside +// the loop, then fire a 2-state toggle repeatedly. This isolates per-Fire +// allocation cost from Cast overhead and is the primary benchmark for the +// lite-mode optimization target (< 10 allocs/op). +func BenchmarkFireSteady(b *testing.B) { + m := buildBenchToggleMachine() + ctx := context.Background() + + b.Run("lite", func(b *testing.B) { + inst := m.Cast(nil, state.WithInitialState[benchToggleState](benchOn)) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + inst.Fire(ctx, benchFlip) + } + }) + + b.Run("full", func(b *testing.B) { + inst := m.Cast(nil, + state.WithInitialState[benchToggleState](benchOn), + state.WithFullTrace[benchToggleState](), + ) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + inst.Fire(ctx, benchFlip) + } + }) +} + // BenchmarkLoadFromJSON measures the IR parse hot path on a representative // machine definition. func BenchmarkLoadFromJSON(b *testing.B) { diff --git a/state/cascade.go b/state/cascade.go index cb41036..643e9b8 100644 --- a/state/cascade.go +++ b/state/cascade.go @@ -147,7 +147,7 @@ func (i *Instance[S, E, C]) settleDone(to S, entity C, tr *Trace) (effects []Eff if !ok { return effects, "", nil } - tr.Microsteps = append(tr.Microsteps, "done."+fmtState(cur)) + tr.note("done." + i.machine.label(cur)) if !i.stateComplete(parent) { return effects, "", nil } diff --git a/state/conformance/scenario.go b/state/conformance/scenario.go index 0eb8616..37fad35 100644 --- a/state/conformance/scenario.go +++ b/state/conformance/scenario.go @@ -165,7 +165,9 @@ func RunAgainst[S comparable, E comparable, C any]( codec EventCodec[E], startState S, ) ScenarioResult[S] { - inst := m.Cast(entity, state.WithInitialState(startState)) + // Full trace is required so EffectsEmitted, GuardsEvaluated, and the cascade + // fields are populated for scenario assertion evaluation. + inst := m.Cast(entity, state.WithInitialState(startState), state.WithFullTrace[S]()) res := ScenarioResult[S]{FinalState: startState} tr := Trace{MachineID: m.Name(), FromState: sc.InitialState} diff --git a/state/coverage_test.go b/state/coverage_test.go index 010bd9a..0c481bb 100644 --- a/state/coverage_test.go +++ b/state/coverage_test.go @@ -229,7 +229,7 @@ func TestParallel_AncestorHandlesEvent(t *testing.T) { func TestCancel_FromHierarchicalSubstate(t *testing.T) { m := buildJobMachine() job := &Job{Status: Queued} - inst := m.Cast(job) + inst := m.Cast(job, state.WithFullTrace[JobStatus]()) inst.Fire(context.Background(), Enqueue) // -> Starting (inside Running) res := inst.Fire(context.Background(), Cancel) if res.Err != nil { diff --git a/state/determinism_test.go b/state/determinism_test.go index 671b02d..6b45be2 100644 --- a/state/determinism_test.go +++ b/state/determinism_test.go @@ -254,7 +254,7 @@ func projectOrder(res state.FireResult[ordState], cur ordCtx) orderTrace { func runOrderingScenario(t *testing.T) []orderTrace { t.Helper() m := forgeOrdering() - inst := m.Cast(ordCtx{}, state.WithInitialState[ordState](ordStart)) + inst := m.Cast(ordCtx{}, state.WithInitialState[ordState](ordStart), state.WithFullTrace[ordState]()) events := []ordEvent{ordGo, ordAlphaE, ordBetaE, ordCross} steps := make([]orderTrace, 0, len(events)) @@ -285,7 +285,7 @@ func TestGoldenEmissionOrder(t *testing.T) { // iteration) reorders EffectsEmitted / AssignsApplied / ContextLog and fails here. func TestGoldenCrossRegionFold(t *testing.T) { m := forgeOrdering() - inst := m.Cast(ordCtx{}, state.WithInitialState[ordState](ordStart)) + inst := m.Cast(ordCtx{}, state.WithInitialState[ordState](ordStart), state.WithFullTrace[ordState]()) // Drive into the parallel configuration, then fire the shared Tick. for _, ev := range []ordEvent{ordGo, ordTick} { res := inst.Fire(context.Background(), ev) diff --git a/state/doc.go b/state/doc.go index 2874a53..0e633e4 100644 --- a/state/doc.go +++ b/state/doc.go @@ -171,10 +171,26 @@ // // Observability is Trace-first: the structured Trace is the canonical surface, // recording matched transitions, guard and policy evaluations, emitted effects, -// and the outcome as pure data. An optional WithLogger(*slog.Logger) (no-op by -// default) is the only logging seam; the kernel never logs unless asked and -// never imports a third-party logger. Determinism is preserved by injecting -// time and identifier seams rather than calling time.Now or rand directly. +// and the outcome as pure data. Observability is opt-in, in keeping with the +// suite's no-op-default convention. By default an instance runs in LITE trace +// mode: each Fire still returns a Trace, but only the always-present fields +// (Machine, Event, FromState, MatchedAt, Outcome) are populated — enough for +// structured logging and a settled result, with no per-step diagnostic +// allocation. FULL trace mode populates the rich per-step fields +// (GuardsEvaluated, EffectsEmitted, ExitedStates, EnteredStates, AssignsApplied, +// Microsteps, EventPayload, SelectedTransition) and is enabled by attaching any +// observer at Cast: WithFullTrace, WithInspector, or one of the history options +// below. WithLogger(*slog.Logger) (no-op by default) reads only the always-present +// fields, so a logger-only instance stays lite; the kernel never logs unless +// asked and never imports a third-party logger. +// +// Trace history is retained only when requested, and is bounded by default: +// WithHistory(n) keeps the last n settled traces in a ring buffer (n <= 0 +// disables retention), so a long-lived instance never grows its history without +// limit. WithUnboundedHistory opts into unbounded retention when a consumer +// genuinely wants every trace. With no history option, History returns nil. +// Determinism is preserved by injecting time and identifier seams rather than +// calling time.Now or rand directly. // // As a library, the kernel never exits the process — it never calls os.Exit or // log.Fatal on an operational error. Panics are reserved strictly for @@ -183,7 +199,7 @@ // # Status // // The kernel implements the Forge/Temper/Quench build path, Cast/Fire pure step -// semantics with guards, actions, typed errors and an always-recorded Trace, +// semantics with guards, actions, typed errors and an opt-in structured Trace, // Assay/Requirements, PlanPath (BFS), FireSeq/FireEach batch helpers, and // lossless ToJSON/LoadFromJSON/Provide round-trip. // diff --git a/state/exemplar_test.go b/state/exemplar_test.go index 62e11ef..a3f19a5 100644 --- a/state/exemplar_test.go +++ b/state/exemplar_test.go @@ -366,7 +366,8 @@ func newConnHarness() *connHarness { clk := state.NewFakeClock(time.Unix(0, 0)) inst := m.Cast(Link{Admitted: true, Healthy: true}, - state.WithInitialState(Disconnected), state.WithClock[Conn](clk)) + state.WithInitialState(Disconnected), state.WithClock[Conn](clk), + state.WithFullTrace[Conn]()) run := state.NewServiceRunner(inst, nil) sys := state.NewActorSystem(inst).Register("worker", workerBehavior()) sch := state.NewScheduler(inst) diff --git a/state/fire.go b/state/fire.go index 3f2b6c0..8cfa9b6 100644 --- a/state/fire.go +++ b/state/fire.go @@ -58,7 +58,21 @@ func (i *Instance[S, E, C]) fireWithMiddleware(ctx context.Context, event E) Fir next = mws[k](next) } res := next(ctx, event) - i.history = append(i.history, res.Trace) + // Retain the trace according to the history mode selected at Cast: + // unbounded → append; ring buffer → fill then overwrite; neither → no-op. + switch { + case i.histUnbounded: + i.history = append(i.history, res.Trace) + case i.histLimit > 0: + if len(i.history) < i.histLimit { + // Ring not yet full: fill forward. + i.history = append(i.history, res.Trace) + } else { + // Ring full: overwrite oldest entry at histHead, then advance. + i.history[i.histHead] = res.Trace + i.histHead = (i.histHead + 1) % i.histLimit + } + } // Surface the settled result to a registered inspector as event/transition/ // snapshot observations. The call is gated on a non-nil inspector inside // emitInspection, so an un-inspected Fire is unchanged and performs no IO. @@ -163,14 +177,16 @@ func marshalEventPayload[E comparable](event E) json.RawMessage { // seedTrace builds a fresh Trace for an internal sub-step (a raised event or an // eventless transition), tagged with the active leaf so the microstep record is -// self-describing. +// self-describing. The trace inherits the instance's full mode so all sub-steps +// within a macrostep are internally consistent. func (i *Instance[S, E, C]) seedTrace(event string) Trace { return Trace{ Machine: i.machine.name, Event: event, - FromState: fmtState(i.current), - MatchedAt: fmtState(i.current), + FromState: i.machine.label(i.current), + MatchedAt: i.machine.label(i.current), Outcome: OutcomeInvalidTransition, + full: i.traceFull, } } @@ -219,7 +235,12 @@ func (i *Instance[S, E, C]) guardsPass(t *Transition[S, E, C]) bool { // absorbMicrosteps folds an internal sub-step's observable trace detail into the // macrostep's running trace, preserving order across the run-to-completion loop. +// In lite mode the sub-step carries no rich fields, so the early-return also +// avoids any append calls. func absorbMicrosteps(dst *Trace, sub Trace) { + if !dst.full { + return + } if sub.Event != "" { dst.Microsteps = append(dst.Microsteps, sub.Event) } @@ -258,12 +279,17 @@ func (i *Instance[S, E, C]) fireOnce(ctx context.Context, event E) FireResult[S] from := i.current tr := Trace{ - Machine: m.name, - Event: fmt.Sprint(event), - EventPayload: marshalEventPayload(event), - FromState: fmtState(from), - MatchedAt: fmtState(from), - Outcome: OutcomeInvalidTransition, + Machine: m.name, + Event: fmt.Sprint(event), + FromState: m.label(from), + MatchedAt: m.label(from), + Outcome: OutcomeInvalidTransition, + full: i.traceFull, + } + // EventPayload is only marshaled in full mode: it allocates and the + // journal/replay seam is not needed when no observer reads the trace. + if i.traceFull { + tr.EventPayload = marshalEventPayload(event) } if _, ok := m.stateByName(from); !ok { @@ -319,9 +345,9 @@ func (i *Instance[S, E, C]) fireSpine(ctx context.Context, event E, tr Trace) Fi // bubble: distinct from "no handler", which would keep climbing. The event // is ignored — no state change, no effects, a success outcome. if forbids(n.state, event) { - tr.MatchedAt = fmtState(anc) + tr.MatchedAt = m.label(anc) tr.Outcome = OutcomeSuccess - tr.Microsteps = append(tr.Microsteps, "forbidden."+fmt.Sprint(event)+"@"+fmtState(anc)) + tr.note("forbidden." + fmt.Sprint(event) + "@" + m.label(anc)) return FireResult[S]{NewState: from, Trace: tr} } candidates := matchingTransitions(n.state, event) @@ -331,7 +357,7 @@ func (i *Instance[S, E, C]) fireSpine(ctx context.Context, event E, tr Trace) Fi for _, t := range candidates { passed := true for _, g := range t.Guards { - tr.GuardsEvaluated = append(tr.GuardsEvaluated, g.Name) + tr.recordGuard(g.Name) ok, gErr := m.evalGuard(g, entity) if gErr != nil { tr.Outcome = OutcomeGuardPanic @@ -364,7 +390,7 @@ func (i *Instance[S, E, C]) fireSpine(ctx context.Context, event E, tr Trace) Fi } } if passed { - tr.MatchedAt = fmtState(anc) + tr.MatchedAt = m.label(anc) return i.commit(ctx, t, from, anc, entity, i.eventData(event), tr) } } @@ -443,7 +469,11 @@ func (i *Instance[S, E, C]) commit( _ = ctx m := i.machine - tr.SelectedTransition = projectTransition(t) + // SelectedTransition calls projectTransition which allocates several slices; + // skip it entirely in lite mode. + if tr.full { + tr.SelectedTransition = projectTransition(t) + } // cur threads the context by value through the cascade. Actions in a phase read // cur as it stood at phase entry (read-only); the assigns of that phase then @@ -525,7 +555,7 @@ func (i *Instance[S, E, C]) commit( // Exit actions then exit assigns: innermost -> outermost. Each state's exit // actions read cur (pre-assign), then its exit assigns fold cur. for _, s := range exits { - tr.ExitedStates = append(tr.ExitedStates, fmtState(s)) + tr.recordExit(m.label(s)) n, ok := m.resolveNode(s) if !ok { continue @@ -609,7 +639,7 @@ func (i *Instance[S, E, C]) commit( // Entry actions then entry assigns: outermost -> innermost. Each state's entry // actions read cur (pre-assign), then its entry assigns fold cur. for _, s := range entries { - tr.EnteredStates = append(tr.EnteredStates, fmtState(s)) + tr.recordEntry(m.label(s)) n, ok := m.resolveNode(s) if !ok { continue @@ -702,11 +732,13 @@ func (i *Instance[S, E, C]) isInternal(t *Transition[S, E, C], from S) bool { func (i *Instance[S, E, C]) enqueueRaised(t *Transition[S, E, C], tr *Trace) { for _, ev := range t.Raise { i.raised = append(i.raised, ev) - tr.Microsteps = append(tr.Microsteps, "raise."+fmt.Sprint(ev)) + tr.note("raise." + fmt.Sprint(ev)) } } -// transName renders a transition label for diagnostics. +// transName renders a transition label for diagnostics. Sites that hold a +// machine pointer should call m.label instead; this free function is retained +// for cascade.go and other files that pass already-formatted strings. func transName[S comparable](from, to S) string { return fmt.Sprintf("%s->%s", fmtState(from), fmtState(to)) } @@ -718,13 +750,13 @@ func (i *Instance[S, E, C]) runActions(refs []Ref, entity C, tr *Trace) (effects for _, a := range refs { e, aerr := i.machine.evalAction(a, entity) if aerr != nil { - tr.EffectsEmitted = append(tr.EffectsEmitted, a.Name) + tr.recordEffect(a.Name) return effects, a.Name, aerr } effects = append(effects, e) - tr.EffectsEmitted = append(tr.EffectsEmitted, fmt.Sprintf("%s:%s", a.Name, effectLabel(e))) + tr.recordEffect(fmt.Sprintf("%s:%s", a.Name, effectLabel(e))) if ms, ok := commMicrostep(e); ok { - tr.Microsteps = append(tr.Microsteps, ms) + tr.note(ms) } } return effects, "", nil @@ -768,11 +800,11 @@ func (i *Instance[S, E, C]) applyAssigns(refs []Ref, cur C, eventData any, tr *T for _, a := range refs { next, err := i.machine.evalAssign(a, cur, eventData) if err != nil { - tr.AssignsApplied = append(tr.AssignsApplied, a.Name) + tr.recordAssign(a.Name) return cur, a.Name, err } cur = next - tr.AssignsApplied = append(tr.AssignsApplied, a.Name) + tr.recordAssign(a.Name) } return cur, "", nil } @@ -839,7 +871,7 @@ func (i *Instance[S, E, C]) FireSeq(ctx context.Context, events []E, opts ...Fir } var br BatchResult[S] - merged := Trace{Machine: i.machine.name, Outcome: OutcomeSuccess} + merged := Trace{Machine: i.machine.name, Outcome: OutcomeSuccess, full: i.traceFull} for _, ev := range events { res := i.Fire(ctx, ev, opts...) br.Steps = append(br.Steps, res) @@ -861,8 +893,12 @@ func (i *Instance[S, E, C]) FireSeq(ctx context.Context, events []E, opts ...Fir } // mergeTrace appends one step's trace into the running merged trace, preserving -// order across the batch. +// order across the batch. In lite mode the per-step rich fields are empty so the +// early-return avoids spurious append calls. func mergeTrace(dst *Trace, step Trace) { + if !dst.full { + return + } if step.Event != "" { dst.Microsteps = append(dst.Microsteps, step.Event) } diff --git a/state/guard.go b/state/guard.go index e2addb6..bdbcf8b 100644 --- a/state/guard.go +++ b/state/guard.go @@ -372,7 +372,7 @@ func (i *Instance[S, E, C]) evalGuardExpr(g *GuardNode[S], entity C, tr *Trace) name = g.Ref.Name } if tr != nil { - tr.GuardsEvaluated = append(tr.GuardsEvaluated, name) + tr.recordGuard(name) } ok, err := i.machine.evalGuard(*g.Ref, entity) if err != nil { @@ -386,7 +386,7 @@ func (i *Instance[S, E, C]) evalGuardExpr(g *GuardNode[S], entity C, tr *Trace) case GuardStateIn: name := stateInName(*g.In) if tr != nil { - tr.GuardsEvaluated = append(tr.GuardsEvaluated, name) + tr.recordGuard(name) } if i.inConfiguration(*g.In) { return guardEval{ok: true} @@ -439,7 +439,7 @@ func (i *Instance[S, E, C]) evalGuardExpr(g *GuardNode[S], entity C, tr *Trace) // trace records the rendered expression so a failure names the comparison. name := renderGuardExpr(g) if tr != nil { - tr.GuardsEvaluated = append(tr.GuardsEvaluated, name) + tr.recordGuard(name) } ok, err := evalCorePredicate(g, entity) if err != nil { diff --git a/state/hardening_test.go b/state/hardening_test.go index 76155fd..ef5a598 100644 --- a/state/hardening_test.go +++ b/state/hardening_test.go @@ -116,13 +116,14 @@ func TestRoundTrip_ByteIdentity(t *testing.T) { } } -// TestHistory_RecordsEveryFire asserts an instance accumulates a trace per Fire. +// TestHistory_RecordsEveryFire asserts an instance accumulates a trace per Fire +// when unbounded history retention is enabled. func TestHistory_RecordsEveryFire(t *testing.T) { m, rec := safeBuild(t) if rec != nil { t.Skipf("build not implemented yet: %v", rec) } - inst := m.Cast(&Document{Status: Draft}) + inst := m.Cast(&Document{Status: Draft}, state.WithUnboundedHistory[DocState]()) inst.Fire(context.Background(), Submit) inst.Fire(context.Background(), Archive) // Submitted->Archive is a valid transition if got := len(inst.History()); got != 2 { diff --git a/state/hsm.go b/state/hsm.go index 1346d37..7eb80e7 100644 --- a/state/hsm.go +++ b/state/hsm.go @@ -1,5 +1,7 @@ package state +import "fmt" + // This file holds the hierarchical and orthogonal (parallel) machinery layered // over the flat kernel. A flat machine exercises none of it: every flat state // indexes as a depthless leaf with no parent and no regions, so resolution and @@ -18,16 +20,86 @@ type node[S comparable, E comparable, C any] struct { // is a parallel state. Empty for compound-state children and top-level nodes. region string depth int + + // ancestorChain is the precomputed innermost-first ancestor list for this + // node: [name, parent, grandparent, ...]. It is set in a second pass of + // indexHierarchy after all nodes are wired. ancestors() returns it directly + // (shared, read-only — callers only range over it). An unknown state falls + // back to the walk-based path. + ancestorChain []S + + // leaves is the precomputed descendToLeaves result for this node. It is set + // in a second pass of indexHierarchy. descendToLeaves() returns a copy to + // prevent the caller's mutable config slice from aliasing the cache. + leaves []S } // indexHierarchy walks the (nested) state graph and records a node per state, -// wiring parent and region membership. It runs once at Quench. The top-level -// states drive the walk; Children and Regions recurse. +// wiring parent and region membership. It runs once at Quench. After the tree +// is wired it makes a second pass to precompute ancestorChain and leaves for +// every node, and populates the machine's label cache. func (m *Machine[S, E, C]) indexHierarchy() { m.nodes = map[S]*node[S, E, C]{} for i := range m.states { m.walkNode(&m.states[i], *new(S), false, "", 0) } + + // Second pass: precompute ancestor chains, leaves, and the label cache now + // that every node is wired (parent pointers are stable). + m.labels = make(map[S]string, len(m.nodes)) + for name, n := range m.nodes { + n.ancestorChain = m.walkAncestors(name) + n.leaves = m.walkLeaves(name) + m.labels[name] = fmt.Sprint(name) + } +} + +// walkAncestors builds the innermost-first ancestor chain for name by following +// parent pointers. It is called once per node at Quench; the result is cached on +// the node and returned by ancestors() on the hot path. +func (m *Machine[S, E, C]) walkAncestors(name S) []S { + var chain []S + cur := name + for { + n, ok := m.nodes[cur] + if !ok { + break + } + chain = append(chain, cur) + if !n.hasParent { + break + } + cur = n.parent + } + return chain +} + +// walkLeaves computes the descendToLeaves result for name without consulting +// the cached leaves field (used during the second pass before caches are set). +func (m *Machine[S, E, C]) walkLeaves(name S) []S { + n, ok := m.nodes[name] + if !ok { + return nil + } + s := n.state + switch { + case isParallel(s): + var leaves []S + for ri := range s.Regions { + r := &s.Regions[ri] + if r.InitialChild != nil { + leaves = append(leaves, m.walkLeaves(*r.InitialChild)...) + } + } + return leaves + case isCompound(s): + if s.InitialChild == nil { + return []S{name} + } + return m.walkLeaves(*s.InitialChild) + default: + return []S{name} + } } // walkNode records one state and recurses into its children and regions. @@ -71,65 +143,49 @@ func isParallel[S comparable, E comparable, C any](s *State[S, E, C]) bool { } // descendToLeaves returns the active leaves reached by entering name and -// cascading into its initial children. For a leaf it returns [name]; for a -// compound state it follows InitialChild transitively; for a parallel state it -// concatenates each region's descent, in region-declaration order. +// cascading into its initial children. For a leaf it returns a copy of +// [name]; for a compound state it follows InitialChild transitively; for a +// parallel state it concatenates each region's descent, in region-declaration +// order. The returned slice is always a fresh copy so the caller's mutable +// config field cannot alias the precomputed cache. func (m *Machine[S, E, C]) descendToLeaves(name S) []S { - n, ok := m.resolveNode(name) + n, ok := m.nodes[name] if !ok { return nil } - s := n.state - switch { - case isParallel(s): - var leaves []S - for ri := range s.Regions { - r := &s.Regions[ri] - if r.InitialChild != nil { - leaves = append(leaves, m.descendToLeaves(*r.InitialChild)...) - } - } - return leaves - case isCompound(s): - if s.InitialChild == nil { - return []S{name} - } - return m.descendToLeaves(*s.InitialChild) - default: - return []S{name} + if len(n.leaves) > 0 { + // Return a copy: the caller assigns the result into i.config, which is + // mutable. Aliasing the cache would corrupt the precomputed slice. + return append([]S(nil), n.leaves...) } + // A leaf node's cached leaves is [name]; still return a copy. + return append([]S(nil), n.leaves...) } // ancestors returns name and its ancestors, innermost-first (name, parent, ...). +// The returned slice is shared (read-only cache); callers must not modify it. +// Falls back to a live walk for states not in the node index (dynamic or +// unknown names — should not occur in a Quenched machine, but defensive). func (m *Machine[S, E, C]) ancestors(name S) []S { - var chain []S - cur := name - for { - n, ok := m.resolveNode(cur) - if !ok { - break - } - chain = append(chain, cur) - if !n.hasParent { - break - } - cur = n.parent + if n, ok := m.nodes[name]; ok { + return n.ancestorChain } - return chain + // Fallback: walk on demand (unknown state; defensive path). + return m.walkAncestors(name) } -// lca returns the least common ancestor of two states, walking both ancestor -// chains. ok is false when the states share no ancestor (distinct top-level -// spines), in which case the cascade exits/enters to the root. +// lca returns the least common ancestor of two states, walking both precomputed +// ancestor chains with a nested linear scan. ok is false when the states share +// no ancestor (distinct top-level spines), in which case the cascade exits/enters +// to the root. The scan is allocation-free; hierarchies are shallow in practice. func (m *Machine[S, E, C]) lca(a, b S) (lca S, ok bool) { aChain := m.ancestors(a) - inA := map[S]bool{} - for _, s := range aChain { - inA[s] = true - } - for _, s := range m.ancestors(b) { - if inA[s] { - return s, true + bChain := m.ancestors(b) + for _, as := range aChain { + for _, bs := range bChain { + if as == bs { + return as, true + } } } return lca, false diff --git a/state/hsm_test.go b/state/hsm_test.go index d7e3e36..2463939 100644 --- a/state/hsm_test.go +++ b/state/hsm_test.go @@ -42,7 +42,7 @@ func TestCompound_EntersInitialChild(t *testing.T) { // when entering a compound state. func TestCompound_EntryCascadeOrder(t *testing.T) { m := buildJobMachine() - inst := m.Cast(&Job{Status: Queued}) + inst := m.Cast(&Job{Status: Queued}, state.WithFullTrace[JobStatus]()) res := inst.Fire(context.Background(), Enqueue) notes := notesFrom(res.Effects) want := []string{"entry:Running", "entry:Starting"} @@ -93,7 +93,7 @@ func TestCompound_BubblesToAncestor(t *testing.T) { // when leaving a compound state via an ancestor transition. func TestCompound_ExitCascadeOrder(t *testing.T) { m := buildJobMachine() - inst := m.Cast(&Job{Status: Executing}) + inst := m.Cast(&Job{Status: Executing}, state.WithFullTrace[JobStatus]()) res := inst.Fire(context.Background(), Cancel) notes := notesFrom(res.Effects) // Exit innermost (Executing) then outermost (Running); Canceled is a leaf diff --git a/state/invoke.go b/state/invoke.go index 43b07a1..fdde40a 100644 --- a/state/invoke.go +++ b/state/invoke.go @@ -153,7 +153,7 @@ func (i *Instance[S, E, C]) invokeEffectsOnEntry(entries []S, tr *Trace) []Effec OnError: inv.OnError, State: fmtState(s), }) - tr.Microsteps = append(tr.Microsteps, "service.start."+id) + tr.note("service.start." + id) } } return out @@ -178,7 +178,7 @@ func (i *Instance[S, E, C]) invokeEffectsOnExit(exits []S, tr *Trace) []Effect { } id := invocationID(m.name, s, ix, inv) out = append(out, StopService{ID: id}) - tr.Microsteps = append(tr.Microsteps, "service.stop."+id) + tr.note("service.stop." + id) } } return out diff --git a/state/journal_test.go b/state/journal_test.go index 464986c..3d14138 100644 --- a/state/journal_test.go +++ b/state/journal_test.go @@ -36,7 +36,7 @@ func jrnlMachine() *state.Machine[string, string, *snapCtx] { // alongside the human-readable Event label, and that the payload round-trips. func TestTrace_EventPayloadRoundTrips(t *testing.T) { m := jrnlMachine() - inst := m.Cast(&snapCtx{}, state.WithInitialState("idle")) + inst := m.Cast(&snapCtx{}, state.WithInitialState("idle"), state.WithFullTrace[string]()) res := inst.Fire(context.Background(), "go") if res.Err != nil { t.Fatalf("Fire: %v", res.Err) diff --git a/state/kernel.go b/state/kernel.go index 0f638fe..f2583af 100644 --- a/state/kernel.go +++ b/state/kernel.go @@ -355,6 +355,13 @@ func (o Outcome) String() string { // added, never renamed or repurposed, and the per-step slices are always in // emission order (the order frozen by the determinism contract; see the package // overview). A field that does not apply to a given Fire is left zero/empty. +// +// Rich diagnostic fields (GuardsEvaluated, EffectsEmitted, ExitedStates, +// EnteredStates, AssignsApplied, Microsteps, EventPayload, SelectedTransition) +// are populated only when the trace is in full mode — enabled by WithFullTrace, +// WithInspector, WithHistory, or WithUnboundedHistory at Cast. Lite mode (the +// default) carries Machine, Event, FromState, MatchedAt, and Outcome, which is +// sufficient for structured logging and no-overhead default operation. type Trace struct { // Machine names the machine the traced instance was cast from. Machine string `json:"machine,omitempty"` @@ -369,25 +376,32 @@ type Trace struct { // EventPayload carries the machine-readable value. It is omitted when the event // has no JSON form (e.g. an internal "always"/raise microstep marker), so the // field is additive and the trace stays deterministic across a JSON round-trip. + // Populated only in full mode. EventPayload json.RawMessage `json:"eventPayload,omitempty"` // FromState is the primary active leaf the event was fired in, before the step. FromState string `json:"fromState,omitempty"` // SelectedTransition is the transition that fired, for in-process tooling. It is // not serialized (json:"-") because behavior is bound, not embedded in the IR; // the serializable record of what happened is the other fields. + // Populated only in full mode. SelectedTransition *Transition[any, any, any] `json:"-"` // GuardsEvaluated names each guard the step evaluated, in evaluation order. + // Populated only in full mode. GuardsEvaluated []string `json:"guardsEvaluated,omitempty"` // PoliciesEvaluated names each policy the step evaluated, in evaluation order. + // Populated only in full mode. PoliciesEvaluated []string `json:"policiesEvaluated,omitempty"` // EffectsEmitted names each effect the step emitted, in emission order — the // human-readable companion to FireResult.Effects (the effect data itself). + // Populated only in full mode. EffectsEmitted []string `json:"effectsEmitted,omitempty"` // AssignsApplied names each assign reducer the step folded, in fold order. + // Populated only in full mode. AssignsApplied []string `json:"assignsApplied,omitempty"` // Microsteps records the run-to-completion interleave — each raised internal // event and eventless ("always") step, plus per-region markers — in the order it // occurred within the macrostep. + // Populated only in full mode. Microsteps []string `json:"microsteps,omitempty"` // MatchedAt names the state whose transition actually fired. For a flat @@ -396,12 +410,69 @@ type Trace struct { MatchedAt string `json:"matchedAt,omitempty"` // ExitedStates and EnteredStates record the transition's exit/entry cascade // in execution order (exit innermost-first, entry outermost-first). - ExitedStates []string `json:"exitedStates,omitempty"` + // Populated only in full mode. + ExitedStates []string `json:"exitedStates,omitempty"` + // EnteredStates records the entry cascade in execution order (outermost-first). + // Populated only in full mode. EnteredStates []string `json:"enteredStates,omitempty"` // Outcome classifies how the Fire settled — success or the specific failure // class that stopped it. It is always set (OutcomeSuccess on a clean step). Outcome Outcome `json:"outcome"` + + // full is the mode gate: when true, the rich diagnostic fields above are + // populated; when false (lite mode, the default) they are skipped. + // Unexported so it is not serialized, not carried by projectTransition, and + // not exposed to consumers — the gate is an internal performance concern. + full bool +} + +// note appends ms to Microsteps. It is a no-op in lite mode. +func (t *Trace) note(ms string) { + if !t.full { + return + } + t.Microsteps = append(t.Microsteps, ms) +} + +// recordGuard appends name to GuardsEvaluated. It is a no-op in lite mode. +func (t *Trace) recordGuard(name string) { + if !t.full { + return + } + t.GuardsEvaluated = append(t.GuardsEvaluated, name) +} + +// recordEffect appends s to EffectsEmitted. It is a no-op in lite mode. +func (t *Trace) recordEffect(s string) { + if !t.full { + return + } + t.EffectsEmitted = append(t.EffectsEmitted, s) +} + +// recordExit appends s to ExitedStates. It is a no-op in lite mode. +func (t *Trace) recordExit(s string) { + if !t.full { + return + } + t.ExitedStates = append(t.ExitedStates, s) +} + +// recordEntry appends s to EnteredStates. It is a no-op in lite mode. +func (t *Trace) recordEntry(s string) { + if !t.full { + return + } + t.EnteredStates = append(t.EnteredStates, s) +} + +// recordAssign appends name to AssignsApplied. It is a no-op in lite mode. +func (t *Trace) recordAssign(name string) { + if !t.full { + return + } + t.AssignsApplied = append(t.AssignsApplied, name) } // FireResult is the result of a single Fire. @@ -1404,6 +1475,22 @@ type Machine[S comparable, E comparable, C any] struct { // surface the discoverable descriptor set of a built machine. It is never // consulted by Fire. reg *Registry[C] + // labels is the precomputed state-name string cache built at Quench time. + // label() looks up declared names here (avoiding fmt.Sprint's reflection boxing + // on every hot-path call) and falls back to fmt.Sprint for any dynamic or + // undeclared value. It is read-only after indexHierarchy returns. + labels map[S]string +} + +// label returns the string rendering of state s, using a precomputed cache for +// declared states and falling back to fmt.Sprint for undeclared or dynamic ones. +// It is a hot-path replacement for the free fmtState function at sites that hold +// a machine pointer. +func (m *Machine[S, E, C]) label(s S) string { + if v, ok := m.labels[s]; ok { + return v + } + return fmt.Sprint(s) } // Palette returns the discoverable descriptor set of the machine's registry — @@ -1469,7 +1556,24 @@ func (m *Machine[S, E, C]) Cast(entity C, opts ...CastOption[S]) *Instance[S, E, if clock == nil { clock = systemClock{} } - inst := &Instance[S, E, C]{machine: m, entity: entity, current: current, clock: clock, inspector: cfg.inspector, logger: cfg.logger} + // Elevation to full trace: inspector attached, or any history retention mode, + // or an explicit WithFullTrace option. Logger-only stays lite: the logger only + // reads Machine, Event, FromState, MatchedAt, and Outcome, all of which are + // always present in lite mode. + historyRetained := cfg.histLimit > 0 || cfg.histUnbounded + traceFull := cfg.inspector != nil || historyRetained || cfg.traceFull + + inst := &Instance[S, E, C]{ + machine: m, + entity: entity, + current: current, + clock: clock, + inspector: cfg.inspector, + logger: cfg.logger, + traceFull: traceFull, + histLimit: cfg.histLimit, + histUnbounded: cfg.histUnbounded, + } // If the starting state is itself compound or parallel, the active // configuration is the set of leaves reached by descending into its initial // children. The primary leaf becomes Current(). @@ -1491,8 +1595,23 @@ type Instance[S comparable, E comparable, C any] struct { // config holds all currently-active leaves. For a flat or single-spine // machine len(config)==1 and config[0]==current; for an active parallel // state it holds one leaf per region, in declaration order. - config []S - history []Trace + config []S + + // traceFull is the per-instance gate for rich trace fields. It is set at Cast + // when any observing consumer is attached: inspector, history retention, or an + // explicit WithFullTrace option. Logger-only instances stay lite. + traceFull bool + + // history is the ring buffer of settled traces. Its shape depends on the + // retention mode selected at Cast: + // - histUnbounded true: append-only slice (current unbounded behavior). + // - histLimit > 0: fixed-capacity ring; histHead is the write position mod limit. + // - neither: no retention; history is always nil. + history []Trace + histLimit int + histUnbounded bool + histHead int + // historyShallow and historyDeep record per-compound history for history // pseudo-states: historyShallow maps a compound to its last active direct // child, historyDeep maps a compound to its last active leaf configuration. @@ -1559,9 +1678,36 @@ func (i *Instance[S, E, C]) Configuration() []S { return append([]S(nil), i.config...) } -// History returns the ordered traces recorded on this instance. +// History returns a copy of the traces recorded on this instance, in +// chronological order (oldest first). It returns nil when no history retention +// mode was selected at Cast (the default). +// +// For a bounded ring buffer (WithHistory), the returned slice contains at most +// limit entries; if fewer than limit fires have occurred it contains all of them. +// For unbounded retention (WithUnboundedHistory) it contains every fired trace. func (i *Instance[S, E, C]) History() []Trace { - return append([]Trace(nil), i.history...) + n := len(i.history) + if n == 0 { + return nil + } + if i.histUnbounded || i.histLimit <= 0 { + // Append-only or no wrap: a simple copy is already in order. + return append([]Trace(nil), i.history...) + } + // Ring buffer: reconstruct chronological order from histHead. histHead is the + // next-write position, so the oldest entry is at histHead (mod n) when the + // ring is full, or at index 0 when fewer than limit entries have been written. + out := make([]Trace, n) + if n < i.histLimit { + // Buffer not yet full: entries are in order starting at index 0. + copy(out, i.history) + return out + } + // Buffer full: oldest entry is at histHead. + for k := 0; k < n; k++ { + out[k] = i.history[(i.histHead+k)%n] + } + return out } // fmtState renders a state value for diagnostics/trace. diff --git a/state/nested_test.go b/state/nested_test.go index a95cffc..3395371 100644 --- a/state/nested_test.go +++ b/state/nested_test.go @@ -189,7 +189,7 @@ func fireLvl(t *testing.T, inst *state.Instance[lvl, lvlEvent, *box], ev lvlEven // running entry actions outermost-first across all three levels. func TestNested_EntryCascadeToDeepestLeaf(t *testing.T) { m := buildNested(t) - inst := m.Cast(&box{State: l0}) + inst := m.Cast(&box{State: l0}, state.WithFullTrace[lvl]()) res := fireLvl(t, inst, enter) if got := inst.Current(); got != c1 { @@ -210,7 +210,7 @@ func TestNested_EntryCascadeToDeepestLeaf(t *testing.T) { // spine innermost-first: C1, C, B, A. func TestNested_ExitCascadeInnermostFirst(t *testing.T) { m := buildNested(t) - inst := m.Cast(&box{State: c1}) + inst := m.Cast(&box{State: c1}, state.WithFullTrace[lvl]()) res := fireLvl(t, inst, park) if got := inst.Current(); got != parked { @@ -264,7 +264,7 @@ func TestNested_BubblesAcrossMultipleAncestors(t *testing.T) { // ancestor's OnDone runs, innermost-first, recorded as done microsteps. func TestNested_OnDonePropagatesUpward(t *testing.T) { m := buildNested(t) - inst := m.Cast(&box{State: c2}) + inst := m.Cast(&box{State: c2}, state.WithFullTrace[lvl]()) res := fireLvl(t, inst, finishC) if got := inst.Current(); got != cFinal { @@ -296,7 +296,7 @@ func TestNested_OnDonePropagatesUpward(t *testing.T) { // not A's default spine leaf C1. This is the capability the v1 DSL gate blocked. func TestNested_DeepHistoryFromDSL(t *testing.T) { m := buildNested(t) - inst := m.Cast(&box{State: l0}) + inst := m.Cast(&box{State: l0}, state.WithFullTrace[lvl]()) fireLvl(t, inst, enter) // L0 -> A -> B -> C -> C1 if got := inst.Current(); got != c1 { diff --git a/state/observability_test.go b/state/observability_test.go new file mode 100644 index 0000000..a1584e0 --- /dev/null +++ b/state/observability_test.go @@ -0,0 +1,560 @@ +package state_test + +// This file covers the opt-in observability surface introduced by the +// centralized trace gate: lite vs full mode behavior, history retention options +// (ring buffer and unbounded), the label cache, and precomputed ancestor / +// descendToLeaves correctness. + +import ( + "context" + "log/slog" + "strings" + "testing" + + "github.com/stablekernel/crucible/state" +) + +// --------------------------------------------------------------------------- +// Helpers: simple flat 2-state toggle machine used by most lite/full tests. +// --------------------------------------------------------------------------- + +type toggleState int + +const ( + toggleA toggleState = iota + toggleB +) + +type toggleEvent int + +const toggleGo toggleEvent = 0 + +// buildToggleMachine returns a minimal flat machine: A -go-> B, B -go-> A. +// It registers an action and a reducer so full-mode traces carry non-empty +// EffectsEmitted and AssignsApplied fields. +func buildToggleMachine() *state.Machine[toggleState, toggleEvent, any] { + return state.Forge[toggleState, toggleEvent, any]("toggle"). + Action("noop", func(ctx state.ActionCtx[any]) (state.Effect, error) { + return struct{}{}, nil + }). + Reducer("id", func(ctx state.AssignCtx[any]) any { return ctx.Entity }). + State(toggleA). + Transition(toggleA).On(toggleGo).GoTo(toggleB).Do("noop").Assign("id"). + State(toggleB). + Transition(toggleB).On(toggleGo).GoTo(toggleA).Do("noop").Assign("id"). + Initial(toggleA). + CurrentStateFn(func(any) toggleState { return toggleA }). + Quench() +} + +// --------------------------------------------------------------------------- +// 1. Lite mode (default): rich fields empty. +// --------------------------------------------------------------------------- + +// TestLite_DefaultOmitsRichFields asserts that a Cast with no trace options +// produces a Trace whose rich diagnostic fields (GuardsEvaluated, EffectsEmitted, +// ExitedStates, EnteredStates, AssignsApplied, Microsteps, EventPayload, +// SelectedTransition) are all empty or nil. Machine, Event, FromState, MatchedAt, +// and Outcome are always populated. +func TestLite_DefaultOmitsRichFields(t *testing.T) { + m := buildToggleMachine() + ctx := context.Background() + + inst := m.Cast(nil, state.WithInitialState[toggleState](toggleA)) + res := inst.Fire(ctx, toggleGo) + if res.Err != nil { + t.Fatalf("Fire err = %v", res.Err) + } + + tr := res.Trace + if tr.Machine == "" { + t.Error("Machine should always be set") + } + if tr.Event == "" { + t.Error("Event should always be set") + } + if tr.FromState == "" { + t.Error("FromState should always be set") + } + if tr.MatchedAt == "" { + t.Error("MatchedAt should always be set") + } + if tr.Outcome != state.OutcomeSuccess { + t.Errorf("Outcome = %v, want OutcomeSuccess", tr.Outcome) + } + + // Rich fields must be empty in lite mode. + if len(tr.GuardsEvaluated) != 0 { + t.Errorf("GuardsEvaluated = %v, want empty in lite mode", tr.GuardsEvaluated) + } + if len(tr.EffectsEmitted) != 0 { + t.Errorf("EffectsEmitted = %v, want empty in lite mode", tr.EffectsEmitted) + } + if len(tr.ExitedStates) != 0 { + t.Errorf("ExitedStates = %v, want empty in lite mode", tr.ExitedStates) + } + if len(tr.EnteredStates) != 0 { + t.Errorf("EnteredStates = %v, want empty in lite mode", tr.EnteredStates) + } + if len(tr.AssignsApplied) != 0 { + t.Errorf("AssignsApplied = %v, want empty in lite mode", tr.AssignsApplied) + } + if len(tr.Microsteps) != 0 { + t.Errorf("Microsteps = %v, want empty in lite mode", tr.Microsteps) + } + if len(tr.EventPayload) != 0 { + t.Errorf("EventPayload = %v, want nil in lite mode", tr.EventPayload) + } + if tr.SelectedTransition != nil { + t.Errorf("SelectedTransition = %v, want nil in lite mode", tr.SelectedTransition) + } +} + +// --------------------------------------------------------------------------- +// 2. Full mode parity: WithFullTrace produces the same trace as the old default. +// --------------------------------------------------------------------------- + +// TestFullTrace_ParityWithOldDefault is the parity gate: for a flat machine a +// WithFullTrace Fire must produce non-empty rich trace fields matching what the +// old default (unbounded history, always-full) would have produced. This +// verifies that the helper-method refactor did not silently drop any field. +func TestFullTrace_ParityWithOldDefault(t *testing.T) { + m := buildToggleMachine() + ctx := context.Background() + + // Full mode. + full := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithFullTrace[toggleState]()) + resF := full.Fire(ctx, toggleGo) + if resF.Err != nil { + t.Fatalf("full Fire err = %v", resF.Err) + } + + tr := resF.Trace + if len(tr.EffectsEmitted) == 0 { + t.Error("full mode: EffectsEmitted should be populated") + } + if len(tr.AssignsApplied) == 0 { + t.Error("full mode: AssignsApplied should be populated") + } + if len(tr.EventPayload) == 0 { + t.Error("full mode: EventPayload should be populated") + } + if tr.SelectedTransition == nil { + t.Error("full mode: SelectedTransition should be non-nil") + } +} + +// TestFullTrace_HSM_ParityExitEntry verifies that WithFullTrace on a compound +// machine populates ExitedStates and EnteredStates exactly as the old default. +func TestFullTrace_HSM_ParityExitEntry(t *testing.T) { + m := buildJobMachine() + ctx := context.Background() + + full := m.Cast(&Job{Status: Executing}, state.WithFullTrace[JobStatus]()) + res := full.Fire(ctx, Cancel) + if res.Err != nil { + t.Fatalf("Fire err = %v", res.Err) + } + if len(res.Trace.ExitedStates) == 0 { + t.Error("full mode: ExitedStates should be populated for HSM exit cascade") + } +} + +// --------------------------------------------------------------------------- +// 3. Elevation: WithInspector and WithHistory both elevate to full. +// --------------------------------------------------------------------------- + +// TestElevation_InspectorElevatesTrace asserts that attaching an inspector +// (WithInspector) elevates to full trace without needing WithFullTrace. +func TestElevation_InspectorElevatesTrace(t *testing.T) { + m := buildToggleMachine() + ctx := context.Background() + + sawEvent := false + insp := state.InspectorFunc(func(ev state.InspectionEvent) { + sawEvent = true + }) + inst := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithInspector[toggleState](insp)) + res := inst.Fire(ctx, toggleGo) + if res.Err != nil { + t.Fatalf("Fire err = %v", res.Err) + } + if !sawEvent { + t.Error("inspector should have been called") + } + // Inspector implies full trace. + if len(res.Trace.EffectsEmitted) == 0 { + t.Error("WithInspector should elevate to full trace; EffectsEmitted empty") + } +} + +// TestElevation_WithHistoryElevatesTrace asserts that WithHistory also elevates +// to full trace. +func TestElevation_WithHistoryElevatesTrace(t *testing.T) { + m := buildToggleMachine() + ctx := context.Background() + + inst := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithHistory[toggleState](5)) + res := inst.Fire(ctx, toggleGo) + if res.Err != nil { + t.Fatalf("Fire err = %v", res.Err) + } + if len(res.Trace.EffectsEmitted) == 0 { + t.Error("WithHistory should elevate to full trace; EffectsEmitted empty") + } +} + +// --------------------------------------------------------------------------- +// 4. Logger-only stays lite. +// --------------------------------------------------------------------------- + +// TestLoggerOnly_StaysLite asserts that attaching only a *slog.Logger (no +// inspector, no history, no WithFullTrace) keeps the trace in lite mode. The +// logger can still log because it reads Machine, Event, FromState, MatchedAt, +// and Outcome — all of which are always set in lite mode. +func TestLoggerOnly_StaysLite(t *testing.T) { + m := buildToggleMachine() + ctx := context.Background() + + // Discard logger: we only care that rich fields are absent. + logger := slog.New(slog.NewTextHandler(newDiscardWriter(), nil)) + inst := m.Cast(nil, + state.WithInitialState[toggleState](toggleA), + state.WithLogger[toggleState](logger), + ) + res := inst.Fire(ctx, toggleGo) + if res.Err != nil { + t.Fatalf("Fire err = %v", res.Err) + } + if len(res.Trace.EffectsEmitted) != 0 { + t.Errorf("logger-only: EffectsEmitted should be empty in lite mode, got %v", res.Trace.EffectsEmitted) + } + if len(res.Trace.AssignsApplied) != 0 { + t.Errorf("logger-only: AssignsApplied should be empty in lite mode, got %v", res.Trace.AssignsApplied) + } + if tr := res.Trace; tr.Machine == "" || tr.Event == "" || tr.FromState == "" || tr.Outcome != state.OutcomeSuccess { + t.Errorf("logger-only: always-populated fields missing: %+v", res.Trace) + } +} + +// discardWriter implements io.Writer and discards all output. +type discardWriter struct{} + +func newDiscardWriter() *discardWriter { return &discardWriter{} } +func (w *discardWriter) Write(p []byte) (int, error) { return len(p), nil } + +// --------------------------------------------------------------------------- +// 5. Ring buffer: bounded history. +// --------------------------------------------------------------------------- + +// TestRingBuffer_RetainsExactlyLimit asserts the ring buffer keeps the last +// limit entries when more than limit fires occur. +func TestRingBuffer_RetainsExactlyLimit(t *testing.T) { + const limit = 3 + m := buildToggleMachine() + ctx := context.Background() + + inst := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithHistory[toggleState](limit)) + // Fire more than limit times. + for range 7 { + inst.Fire(ctx, toggleGo) + } + h := inst.History() + if len(h) != limit { + t.Errorf("History len = %d, want %d", len(h), limit) + } +} + +// TestRingBuffer_ChronologicalOrder asserts History() returns entries in +// chronological (oldest→newest) order after the ring has wrapped. +func TestRingBuffer_ChronologicalOrder(t *testing.T) { + const limit = 3 + m := buildToggleMachine() + ctx := context.Background() + + inst := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithHistory[toggleState](limit)) + // Fire 5 times; ring wraps after 3, so the kept entries are fires 3,4,5. + for range 5 { + inst.Fire(ctx, toggleGo) + } + h := inst.History() + if len(h) != limit { + t.Fatalf("History len = %d, want %d", len(h), limit) + } + // Entries should be in chronological order: successive fires move between + // toggleA and toggleB, so FromState alternates. + for k := 1; k < len(h); k++ { + if h[k].FromState == h[k-1].FromState { + t.Errorf("History not in chronological order at [%d]: %q == %q", + k, h[k].FromState, h[k-1].FromState) + } + } +} + +// TestRingBuffer_DropsOldest asserts that after the ring fills, the oldest +// entry is dropped when a new one is written. +func TestRingBuffer_DropsOldest(t *testing.T) { + const limit = 2 + m := buildToggleMachine() + ctx := context.Background() + + inst := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithHistory[toggleState](limit)) + // Fire 1: A→B (FromState="0" i.e. toggleA) + inst.Fire(ctx, toggleGo) + // Fire 2: B→A (FromState="1" i.e. toggleB) + inst.Fire(ctx, toggleGo) + // Fire 3: A→B — ring wraps, fire 1 is dropped + inst.Fire(ctx, toggleGo) + + h := inst.History() + if len(h) != limit { + t.Fatalf("History len = %d, want %d", len(h), limit) + } + // The kept entries are fires 2 and 3. + // Fire 2: FromState = toggleB (1) + // Fire 3: FromState = toggleA (0) + want0 := "1" // toggleB + want1 := "0" // toggleA + if h[0].FromState != want0 { + t.Errorf("History[0].FromState = %q, want %q (fire 2, oldest kept)", h[0].FromState, want0) + } + if h[1].FromState != want1 { + t.Errorf("History[1].FromState = %q, want %q (fire 3, newest)", h[1].FromState, want1) + } +} + +// TestRingBuffer_WrapAroundCorrectness stresses the wrap-around at an odd limit +// to confirm modular arithmetic is correct. +func TestRingBuffer_WrapAroundCorrectness(t *testing.T) { + const ( + limit = 5 + numFire = 13 + ) + m := buildToggleMachine() + ctx := context.Background() + + // Track every FromState in a reference slice. + var ref []string + inst := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithHistory[toggleState](limit)) + for range numFire { + res := inst.Fire(ctx, toggleGo) + ref = append(ref, res.Trace.FromState) + } + // Expected: the last `limit` fires' FromState values. + wantRef := ref[numFire-limit:] + got := inst.History() + if len(got) != limit { + t.Fatalf("History len = %d, want %d", len(got), limit) + } + for k := range limit { + if got[k].FromState != wantRef[k] { + t.Errorf("History[%d].FromState = %q, want %q", k, got[k].FromState, wantRef[k]) + } + } +} + +// TestRingBuffer_ZeroLimitDisables asserts WithHistory(0) does not retain any +// traces and History() returns nil. +func TestRingBuffer_ZeroLimitDisables(t *testing.T) { + m := buildToggleMachine() + ctx := context.Background() + + inst := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithHistory[toggleState](0)) + inst.Fire(ctx, toggleGo) + if h := inst.History(); h != nil { + t.Errorf("WithHistory(0) should disable retention; got %v", h) + } +} + +// TestRingBuffer_UnboundedGrows asserts WithUnboundedHistory retains every +// settled trace and History() grows without limit. +func TestRingBuffer_UnboundedGrows(t *testing.T) { + const numFire = 10 + m := buildToggleMachine() + ctx := context.Background() + + inst := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithUnboundedHistory[toggleState]()) + for range numFire { + inst.Fire(ctx, toggleGo) + } + if got := len(inst.History()); got != numFire { + t.Errorf("WithUnboundedHistory: History len = %d, want %d", got, numFire) + } +} + +// TestHistory_DefaultReturnsNil asserts History() returns nil (no allocation) +// when no retention mode is selected. +func TestHistory_DefaultReturnsNil(t *testing.T) { + m := buildToggleMachine() + ctx := context.Background() + + inst := m.Cast(nil, state.WithInitialState[toggleState](toggleA)) + inst.Fire(ctx, toggleGo) + if h := inst.History(); h != nil { + t.Errorf("default: History() should return nil, got %v", h) + } +} + +// --------------------------------------------------------------------------- +// 5b. Restore trace mode: WithRestoreFullTrace / WithRestoreUnboundedHistory. +// --------------------------------------------------------------------------- + +// TestRestore_TraceModeOptIn asserts a restored instance is lite by default (a +// subsequent Fire omits rich fields and does not retain new traces), that +// WithRestoreFullTrace restores full per-step traces without continued retention, +// and that WithRestoreUnboundedHistory restores full traces AND keeps retaining +// them — the mode the durable runner relies on for byte-identical replay. (Restore +// always seeds history from the snapshot's traces, so the distinguishing behavior +// is whether later Fires are rich and whether they keep accumulating.) +func TestRestore_TraceModeOptIn(t *testing.T) { + m := buildToggleMachine() + ctx := context.Background() + + src := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithUnboundedHistory[toggleState]()) + src.Fire(ctx, toggleGo) + snap := src.Snapshot() + + // Default restore: lite Fire (no rich fields) and no further retention. + lite, err := m.Restore(snap) + if err != nil { + t.Fatalf("Restore (default): %v", err) + } + base := len(lite.History()) + r := lite.Fire(ctx, toggleGo) + if len(r.Trace.EnteredStates) != 0 || len(r.Trace.EffectsEmitted) != 0 { + t.Errorf("default restore should fire lite, got rich fields: %+v", r.Trace) + } + if got := len(lite.History()); got != base { + t.Errorf("default restore should not retain new traces: %d -> %d", base, got) + } + + // WithRestoreFullTrace: rich Fire, but still no continued retention. + full, err := m.Restore(snap, state.WithRestoreFullTrace[toggleState]()) + if err != nil { + t.Fatalf("Restore (full trace): %v", err) + } + fbase := len(full.History()) + rf := full.Fire(ctx, toggleGo) + if len(rf.Trace.EnteredStates) == 0 || len(rf.Trace.EffectsEmitted) == 0 { + t.Errorf("WithRestoreFullTrace should populate rich fields, got %+v", rf.Trace) + } + if got := len(full.History()); got != fbase { + t.Errorf("WithRestoreFullTrace should not retain new traces: %d -> %d", fbase, got) + } + + // WithRestoreUnboundedHistory: rich Fire AND continued unbounded retention. + dur, err := m.Restore(snap, state.WithRestoreUnboundedHistory[toggleState]()) + if err != nil { + t.Fatalf("Restore (unbounded): %v", err) + } + dbase := len(dur.History()) + dur.Fire(ctx, toggleGo) + dur.Fire(ctx, toggleGo) + if got := len(dur.History()); got != dbase+2 { + t.Errorf("WithRestoreUnboundedHistory should keep retaining: %d -> %d (want +2)", dbase, got) + } + if h := dur.History(); len(h[len(h)-1].EnteredStates) == 0 { + t.Errorf("retained trace under unbounded restore should be full, got %+v", h[len(h)-1]) + } +} + +// --------------------------------------------------------------------------- +// 6. Label cache: m.label correctness. +// --------------------------------------------------------------------------- + +// TestLabelCache_HotPathUsed asserts that the label cache produces the correct +// string for a declared state in both full and lite mode (the string must match +// fmt.Sprint of the underlying value). +func TestLabelCache_HotPathUsed(t *testing.T) { + m := buildToggleMachine() + ctx := context.Background() + + inst := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithFullTrace[toggleState]()) + res := inst.Fire(ctx, toggleGo) + // FromState should equal fmt.Sprint(toggleA) = "0". + if res.Trace.FromState != "0" { + t.Errorf("FromState = %q, want \"0\" (fmt.Sprint of toggleA)", res.Trace.FromState) + } + if res.Trace.MatchedAt != "0" { + t.Errorf("MatchedAt = %q, want \"0\"", res.Trace.MatchedAt) + } +} + +// --------------------------------------------------------------------------- +// 7. ancestors / descendToLeaves precompute parity. +// --------------------------------------------------------------------------- + +// TestAncestors_FlatMachine asserts that for a flat machine a simple A→B +// transition correctly names the states in ExitedStates and EnteredStates +// (no compound ancestors; each is its own single-element chain). +func TestAncestors_FlatMachine(t *testing.T) { + m := buildToggleMachine() + ctx := context.Background() + + inst := m.Cast(nil, state.WithInitialState[toggleState](toggleA), state.WithFullTrace[toggleState]()) + res := inst.Fire(ctx, toggleGo) + if res.Err != nil { + t.Fatalf("Fire err = %v", res.Err) + } + // For a flat machine with an A→B external transition, ExitedStates = [A] + // and EnteredStates = [B] — one element each (no compound ancestors). + if len(res.Trace.ExitedStates) != 1 { + t.Errorf("flat machine A→B: ExitedStates = %v, want 1 element", res.Trace.ExitedStates) + } + if len(res.Trace.EnteredStates) != 1 { + t.Errorf("flat machine A→B: EnteredStates = %v, want 1 element", res.Trace.EnteredStates) + } +} + +// TestDescendToLeaves_CompoundNotAliased asserts that firing multiple times does +// not corrupt the precomputed leaves cache: each fire starts from its own state +// and the instance's config slice never aliases the node's internal cache. +func TestDescendToLeaves_CompoundNotAliased(t *testing.T) { + m := buildJobMachine() + ctx := context.Background() + + // Enter the compound Running state (Queued → Enqueue → Starting inside Running). + inst := m.Cast(&Job{Status: Queued}, state.WithFullTrace[JobStatus]()) + res1 := inst.Fire(ctx, Enqueue) + if res1.Err != nil { + t.Fatalf("Enqueue err = %v", res1.Err) + } + // Current should be the deepest initial leaf, not the compound Running state. + cur1 := inst.Current() + + // Fire again from inside the compound. + res2 := inst.Fire(ctx, Begin) + cur2 := inst.Current() + + // The two current states must differ and must be valid declared leaves. + if cur1 == cur2 { + t.Errorf("current state did not advance: %v == %v", cur1, cur2) + } + // EnteredStates on the second fire must not include Running (it was already active). + for _, s := range res2.Trace.EnteredStates { + if s == "Running" { + t.Errorf("fire 2 EnteredStates should not re-enter Running (not a descendToLeaves alias): %v", res2.Trace.EnteredStates) + } + } +} + +// TestAncestors_NestedParity asserts that the precomputed ancestor chain on a +// nested machine returns the correct innermost-first ordering, matching what +// manually walking parent pointers would give. +func TestAncestors_NestedParity(t *testing.T) { + m := buildNested(t) + ctx := context.Background() + + // Start inside the deep leaf C1. Fire Park: this transitions C1->Parked by + // bubbling up through C, B, A and matching the Park transition on A. The exit + // cascade should be [C1, C, B, A] (innermost-first). + inst := m.Cast(&box{State: c1}, state.WithFullTrace[lvl]()) + res := inst.Fire(ctx, park) + if res.Err != nil { + t.Fatalf("Park err = %v", res.Err) + } + want := "C1,C,B,A" + if got := strings.Join(res.Trace.ExitedStates, ","); got != want { + t.Errorf("ExitedStates = %q, want %q (innermost-first ancestor chain)", got, want) + } +} diff --git a/state/options.go b/state/options.go index d6fd967..f4670e3 100644 --- a/state/options.go +++ b/state/options.go @@ -136,11 +136,14 @@ func WithSendToSystemID(id string) SendOption { type CastOption[S comparable] func(*castConfig[S]) type castConfig[S comparable] struct { - initial S - hasInitial bool - clock Clock - inspector Inspector - logger *slog.Logger + initial S + hasInitial bool + clock Clock + inspector Inspector + logger *slog.Logger + traceFull bool // set by WithFullTrace + histLimit int // set by WithHistory (positive value enables ring buffer) + histUnbounded bool // set by WithUnboundedHistory } // WithClock injects the time seam an instance's delayed-transition driver uses. @@ -192,6 +195,36 @@ func WithInitialState[S comparable](s S) CastOption[S] { } } +// WithFullTrace opts the instance into full trace mode, populating all rich +// diagnostic fields on every FireResult.Trace: GuardsEvaluated, EffectsEmitted, +// ExitedStates, EnteredStates, AssignsApplied, Microsteps, EventPayload, and +// SelectedTransition. Without this option (or WithInspector / WithHistory / +// WithUnboundedHistory), those fields are empty to minimize allocations on +// instances whose traces are not observed. Logger-only instances stay lite. +func WithFullTrace[S comparable]() CastOption[S] { + return func(c *castConfig[S]) { c.traceFull = true } +} + +// WithHistory enables a bounded ring-buffer trace history of capacity limit. +// The last limit settled traces are retained; older entries are overwritten in +// declaration order. limit <= 0 is a no-op (no retention). Implies full trace. +// Use History() to retrieve the stored traces in chronological order. +func WithHistory[S comparable](limit int) CastOption[S] { + return func(c *castConfig[S]) { + if limit > 0 { + c.histLimit = limit + } + } +} + +// WithUnboundedHistory enables append-only trace history: every settled trace is +// retained. This is the previous default behavior, now opt-in. Suitable for +// short-lived or test instances; long-lived production instances should prefer +// WithHistory to bound memory growth. Implies full trace. +func WithUnboundedHistory[S comparable]() CastOption[S] { + return func(c *castConfig[S]) { c.histUnbounded = true } +} + // FireOption configures Fire / FireSeq / FireEach. type FireOption func(*fireConfig) @@ -268,6 +301,8 @@ type RestoreOption[S comparable] func(*restoreConfig[S]) type restoreConfig[S comparable] struct { clock Clock rejectMachineVersion bool + traceFull bool + histUnbounded bool } // RejectMachineVersionMismatch makes Restore enforce the machine DEFINITION @@ -288,6 +323,27 @@ func WithRestoreClock[S comparable](c Clock) RestoreOption[S] { return func(cfg *restoreConfig[S]) { cfg.clock = c } } +// WithRestoreFullTrace restores an instance in full trace mode, mirroring +// WithFullTrace at Cast. A restored instance is lite by default like a freshly +// cast one; a journal/replay consumer that reconstructs from the recorded Trace +// (the durable runner) restores in full mode so the rich per-step fields and the +// EventPayload are produced on every replayed Fire. +func WithRestoreFullTrace[S comparable]() RestoreOption[S] { + return func(cfg *restoreConfig[S]) { cfg.traceFull = true } +} + +// WithRestoreUnboundedHistory restores an instance in full trace mode with +// unbounded history retention, mirroring WithUnboundedHistory at Cast. The durable +// runner uses it on recover: it reconstructs the step ordinal and checkpoint +// snapshots from the retained Trace history, so post-recovery Fires must keep +// retaining every trace exactly as a freshly started durable instance does. +func WithRestoreUnboundedHistory[S comparable]() RestoreOption[S] { + return func(cfg *restoreConfig[S]) { + cfg.traceFull = true + cfg.histUnbounded = true + } +} + // SnapshotCodecOption configures MarshalSnapshot / UnmarshalSnapshot. type SnapshotCodecOption[C any] func(*snapshotCodecConfig[C]) diff --git a/state/parallel.go b/state/parallel.go index 6c09371..9a8860e 100644 --- a/state/parallel.go +++ b/state/parallel.go @@ -54,7 +54,7 @@ func (i *Instance[S, E, C]) fireParallel(ctx context.Context, parallel S, event var effects []Effect var regionErrs []error anyHandled := false - tr.MatchedAt = fmtState(parallel) + tr.MatchedAt = m.label(parallel) // The triggering event's payload (WithEventData) is broadcast to every region: // resolve it once, before the region loop, so the region that actually handles @@ -73,7 +73,7 @@ func (i *Instance[S, E, C]) fireParallel(ctx context.Context, parallel S, event if handled { anyHandled = true effects = append(effects, eff...) - tr.Microsteps = append(tr.Microsteps, fmt.Sprintf("region:%s", r.Name)) + tr.note(fmt.Sprintf("region:%s", r.Name)) if err != nil { regionErrs = append(regionErrs, err) } @@ -145,7 +145,7 @@ func (i *Instance[S, E, C]) fireRegion( matched = true pass := true for _, g := range t.Guards { - tr.GuardsEvaluated = append(tr.GuardsEvaluated, g.Name) + tr.recordGuard(g.Name) okg, gErr := m.evalGuard(g, entity) if gErr != nil { return true, nil, gErr @@ -193,7 +193,7 @@ func (i *Instance[S, E, C]) applyRegionTransition( // read the entity snapshot passed in, consistent with the main commit cascade. var effects []Effect for _, s := range exits { - tr.ExitedStates = append(tr.ExitedStates, fmtState(s)) + tr.recordExit(m.label(s)) if n, ok := m.resolveNode(s); ok { eff, _, _ := i.runActions(n.state.OnExit, entity, tr) effects = append(effects, eff...) @@ -226,7 +226,7 @@ func (i *Instance[S, E, C]) applyRegionTransition( i.entity = next for _, s := range entries { - tr.EnteredStates = append(tr.EnteredStates, fmtState(s)) + tr.recordEntry(m.label(s)) if n, ok := m.resolveNode(s); ok { eff, _, _ := i.runActions(n.state.OnEntry, entity, tr) effects = append(effects, eff...) @@ -278,7 +278,7 @@ func (i *Instance[S, E, C]) settleParallelDone(parallel S, entity C, tr *Trace) if !ok { return nil, "", nil } - tr.Microsteps = append(tr.Microsteps, "done."+fmtState(parallel)) + tr.note("done." + m.label(parallel)) eff, aname, aerr := i.runActions(pn.state.OnDone, entity, tr) if aerr != nil { return eff, aname, aerr @@ -310,15 +310,15 @@ func (i *Instance[S, E, C]) fireFromState(ctx context.Context, start S, event E, continue } if forbids(n.state, event) { - tr.MatchedAt = fmtState(anc) + tr.MatchedAt = m.label(anc) tr.Outcome = OutcomeSuccess - tr.Microsteps = append(tr.Microsteps, "forbidden."+fmt.Sprint(event)+"@"+fmtState(anc)) + tr.note("forbidden." + fmt.Sprint(event) + "@" + m.label(anc)) return FireResult[S]{NewState: from, Trace: tr} } for _, t := range matchingTransitions(n.state, event) { pass := true for _, g := range t.Guards { - tr.GuardsEvaluated = append(tr.GuardsEvaluated, g.Name) + tr.recordGuard(g.Name) okg, gErr := m.evalGuard(g, entity) if gErr != nil { tr.Outcome = OutcomeGuardPanic @@ -340,7 +340,7 @@ func (i *Instance[S, E, C]) fireFromState(ctx context.Context, start S, event E, } } if pass { - tr.MatchedAt = fmtState(anc) + tr.MatchedAt = m.label(anc) return i.commit(ctx, t, start, anc, entity, eventData, tr) } } diff --git a/state/scheduler.go b/state/scheduler.go index af7cd5f..9c7eb14 100644 --- a/state/scheduler.go +++ b/state/scheduler.go @@ -123,7 +123,7 @@ func (i *Instance[S, E, C]) afterEffectsOnEntry(entries []S, tr *Trace) []Effect Event: t.On, State: fmtState(s), }) - tr.Microsteps = append(tr.Microsteps, "schedule."+id) + tr.note("schedule." + id) } } return out @@ -184,7 +184,7 @@ func (i *Instance[S, E, C]) afterEffectsOnExit(exits []S, tr *Trace) []Effect { } id := scheduleID(m.name, s, ti) out = append(out, CancelScheduled{ID: id}) - tr.Microsteps = append(tr.Microsteps, "cancel."+id) + tr.note("cancel." + id) } } return out diff --git a/state/snapshot.go b/state/snapshot.go index 5d63a1c..77c9c3c 100644 --- a/state/snapshot.go +++ b/state/snapshot.go @@ -465,6 +465,8 @@ func (m *Machine[S, E, C]) Restore(snap Snapshot[S, E, C], opts ...RestoreOption historyShallow: copyMap(snap.HistoryShallow), historyDeep: copyLeafMap(snap.HistoryDeep), clock: clock, + traceFull: rcfg.traceFull, + histUnbounded: rcfg.histUnbounded, } return inst, nil } diff --git a/state/snapshot_test.go b/state/snapshot_test.go index cb7f4b6..4b27e28 100644 --- a/state/snapshot_test.go +++ b/state/snapshot_test.go @@ -78,7 +78,7 @@ func parallelSnapMachine() *state.Machine[string, string, *snapCtx] { // mid-run and asserts the snapshot holds its configuration, context, and history. func TestSnapshot_PreservesConfigurationContextHistory(t *testing.T) { m := flatSnapMachine() - inst := m.Cast(&snapCtx{}, state.WithInitialState("idle")) + inst := m.Cast(&snapCtx{}, state.WithInitialState("idle"), state.WithUnboundedHistory[string]()) ctx := context.Background() inst.Fire(ctx, "go") diff --git a/state/transitions_test.go b/state/transitions_test.go index 241dd83..4678d39 100644 --- a/state/transitions_test.go +++ b/state/transitions_test.go @@ -188,7 +188,7 @@ func TestReenter_SelfTransitionRunsExitEntry(t *testing.T) { // Reenter: exit then entry of the source run around the effects. mr := build(true) er := &trec{} - resr := mr.Cast(er, state.WithInitialState("s")).Fire(context.Background(), "ping") + resr := mr.Cast(er, state.WithInitialState("s"), state.WithFullTrace[string]()).Fire(context.Background(), "ping") if resr.Err != nil { t.Fatalf("reenter Fire err = %v", resr.Err) } @@ -220,7 +220,7 @@ func TestRaise_FeedsSameMacrostepRTC(t *testing.T) { Initial("a")) rec := &trec{} - inst := m.Cast(rec, state.WithInitialState("a")) + inst := m.Cast(rec, state.WithInitialState("a"), state.WithFullTrace[string]()) res := inst.Fire(context.Background(), "go") if res.Err != nil { t.Fatalf("Fire(go) err = %v", res.Err)