Skip to content

Commit 69836be

Browse files
committed
fix: roll back configuration on a failed Fire so state stays transactional
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent b5cf05e commit 69836be

6 files changed

Lines changed: 298 additions & 27 deletions

File tree

state/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,17 @@ ready to freeze on sign-off. The `analysis`, `evolution`, `conformance`, and
2121

2222
### Fixed
2323

24+
- A failed `Fire` is now fully transactional on the instance's internal state: when
25+
an action or assign errors or panics partway through a macrostep, the active
26+
configuration, current leaf, context, and recorded history all roll back to their
27+
pre-Fire values, `FireResult.NewState` reports the original state rather than the
28+
abandoned target, and a snapshot taken afterward is identical to one never Fired.
29+
This extends the existing effect-emission transactionality to the configuration —
30+
previously a failed `Fire` left the configuration half-advanced. The successful
31+
path is unchanged: the configuration still advances in place during the macrostep
32+
so entry actions, the done cascade, and the run-to-completion loop observe it. A
33+
partial parallel-region commit (an earlier region committing before a later region
34+
fails) rolls back with the rest of the macrostep.
2435
- A `Raise` declared on a region-internal transition is now delivered instead of
2536
silently dropped (kernel parallel-region commit path).
2637
- A region-internal transition targeting a same-region history pseudostate now

state/doc.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,19 @@
3838
// at dispatch, never silently dropped. Effects stay data the host applies — the
3939
// kernel never executes them.
4040
//
41-
// Effect emission is transactional with respect to the step's outcome: a Fire's
42-
// effects are emitted (returned in FireResult.Effects) only on a fully-successful
43-
// Fire. A Fire that fails partway through its cascade — an action or assign that
44-
// errors or panics — returns the error with NO effects, so a host replaying a
45-
// failed Fire cannot double-apply the effects that ran before the error. (The
46-
// configuration is not rolled back on failure; only effect emission is made
47-
// transactional, which is what a replay observes.)
41+
// A Fire is transactional with respect to the step's outcome: it commits to the
42+
// instance's internal state only on a fully-successful step. A Fire that fails
43+
// partway through its cascade — an action or assign that errors or panics —
44+
// returns the error and is a no-op on the instance's persisted state. The active
45+
// configuration, current leaf, context, and recorded history all roll back to their
46+
// pre-Fire values, and no effects are emitted (returned in FireResult.Effects), so
47+
// a host replaying a failed Fire cannot double-apply the effects that ran before the
48+
// error, and FireResult.NewState reports the original state rather than the abandoned
49+
// target. The configuration still advances in place during a successful macrostep
50+
// (entry actions, the done cascade, and the run-to-completion loop observe the
51+
// advancing configuration); a failure discards that advance. The one thing a failed
52+
// Fire cannot undo is a real-world side effect a host already applied from an effect
53+
// emitted by an earlier, successful step — those are outside the kernel's pure state.
4854
//
4955
// # The definition IR is the spec
5056
//

state/fire.go

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,69 @@ const maxMicrosteps = 10_000
105105
// Trace microsteps. The internal queue is local to this call, so Fire stays
106106
// pure: no clock, no IO.
107107
func (i *Instance[S, E, C]) fireCore(ctx context.Context, event E) FireResult[S] {
108+
// Snapshot the live internal state a macrostep mutates before any commit runs.
109+
// A failed Fire (the triggering transition or any run-to-completion sub-step)
110+
// must be a no-op on the instance's persisted state: configuration, current
111+
// leaf, context, and recorded history all roll back together — matching the
112+
// already-transactional context and effects so a snapshot taken after a failed
113+
// Fire is identical to one never Fired. The successful path is untouched: the
114+
// configuration still advances in place during the macrostep (entry actions,
115+
// the done cascade, and the RTC loop read the advancing configuration), and the
116+
// snapshot is simply discarded on success.
117+
txn := i.beginTxn()
118+
108119
res := i.fireOnce(ctx, event)
120+
if res.Err == nil {
121+
res = i.runToCompletion(ctx, res)
122+
}
109123
if res.Err != nil {
110-
return res
124+
i.rollback(txn)
125+
res.NewState = i.current
111126
}
112-
return i.runToCompletion(ctx, res)
127+
return res
128+
}
129+
130+
// fireTxn captures the live, macrostep-mutable fields of an Instance so a failed
131+
// Fire can restore them, making a failed macrostep a no-op on the instance's
132+
// persisted internal state. It holds the configuration, current leaf, context,
133+
// recorded history maps, and the internal-event queue — every field a commit
134+
// writes before it can fail.
135+
type fireTxn[S comparable, E comparable, C any] struct {
136+
current S
137+
config []S
138+
entity C
139+
historyShallow map[S]S
140+
historyDeep map[S][]S
141+
raised []E
142+
}
143+
144+
// beginTxn snapshots the instance's macrostep-mutable state before a Fire runs. The
145+
// configuration, history maps, and queue are copied (not aliased) so an in-place
146+
// mutation during the macrostep cannot corrupt the saved snapshot; the context and
147+
// current leaf are values. It allocates only the small per-macrostep snapshot and
148+
// never touches a clock or IO, so Fire stays pure.
149+
func (i *Instance[S, E, C]) beginTxn() fireTxn[S, E, C] {
150+
return fireTxn[S, E, C]{
151+
current: i.current,
152+
config: append([]S(nil), i.config...),
153+
entity: i.entity,
154+
historyShallow: copyMap(i.historyShallow),
155+
historyDeep: copyLeafMap(i.historyDeep),
156+
raised: append([]E(nil), i.raised...),
157+
}
158+
}
159+
160+
// rollback restores the instance to the state captured by beginTxn, discarding every
161+
// configuration, context, and history mutation a failed macrostep applied. Effects
162+
// are already suppressed on the failure path (the NTE contract), so after rollback
163+
// the instance is byte-for-byte identical to its pre-Fire state.
164+
func (i *Instance[S, E, C]) rollback(txn fireTxn[S, E, C]) {
165+
i.current = txn.current
166+
i.config = txn.config
167+
i.entity = txn.entity
168+
i.historyShallow = txn.historyShallow
169+
i.historyDeep = txn.historyDeep
170+
i.raised = txn.raised
113171
}
114172

115173
// runToCompletion settles the macrostep after the triggering transition: it
@@ -643,10 +701,13 @@ func forbids[S comparable, E comparable, C any](s *State[S, E, C], event E) bool
643701
return false
644702
}
645703

646-
// commit advances the configuration (before running actions, per the locked
647-
// decision) and runs the exit cascade, the transition's bound actions, and the
648-
// entry cascade — building effects and recording the trace. matchedAt is the
649-
// ancestor whose transition fired (equal to the source leaf for a flat machine).
704+
// commit advances the configuration in place (before running actions, so entry
705+
// actions and the done cascade observe the advancing configuration) and runs the
706+
// exit cascade, the transition's bound actions, and the entry cascade — building
707+
// effects and recording the trace. matchedAt is the ancestor whose transition fired
708+
// (equal to the source leaf for a flat machine). A failure returns early with the
709+
// configuration left advanced; fireCore's transaction rolls it back so the failed
710+
// Fire is a no-op on the instance's persisted state.
650711
func (i *Instance[S, E, C]) commit(
651712
ctx context.Context,
652713
t *Transition[S, E, C],
@@ -678,11 +739,12 @@ func (i *Instance[S, E, C]) commit(
678739
effects, errName, err := i.runActions(t.Effects, cur, &tr)
679740
if err != nil {
680741
tr.Outcome = OutcomeEffectError
681-
// Effects are emitted only on a fully-successful Fire (the NTE
742+
// Effects are emitted only on a fully-successful Fire (the
682743
// transactionality contract): a failed Fire returns no partial effects so
683744
// a host replaying it cannot double-apply the ones that ran before the
684-
// error. Config is intentionally not rolled back (out of scope); only the
685-
// effect emission is transactional.
745+
// error. An internal transition leaves the configuration unchanged, so
746+
// only the context fold is at stake here; fireCore's transaction rolls it
747+
// back along with the rest of the macrostep on failure.
686748
return FireResult[S]{
687749
NewState: i.current, Effects: nil, Trace: tr,
688750
Err: &ActionFailedError{

state/fire_transactional_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package state_test
2+
3+
import (
4+
"context"
5+
"errors"
6+
"reflect"
7+
"testing"
8+
9+
"github.com/stablekernel/crucible/state"
10+
)
11+
12+
// txnCtx is the value context for the failed-Fire transactionality regression. It
13+
// carries a balance an entry assign would fold so a rolled-back Fire is provably a
14+
// context no-op, and a JSON-marshalable shape so the snapshot round-trip works with
15+
// the default codec.
16+
type txnCtx struct {
17+
Balance int `json:"balance"`
18+
}
19+
20+
// TestFire_FailedEntryAction_RollsBackConfiguration pins the full-transactionality
21+
// contract: when a transition's ENTRY action fails, the failed Fire is a no-op on
22+
// the instance's persisted internal state. Config, current state, and context are
23+
// all left at their pre-Fire values, FireResult.NewState reports the ORIGINAL state
24+
// (not the abandoned target), and a snapshot taken afterward round-trips to an
25+
// instance identical to one that never Fired.
26+
//
27+
// The machine moves off -> target on "go"; target's OnEntry action errors. Before
28+
// the fix the kernel advanced i.current/i.config to "target" and reported
29+
// NewState=target while leaving the context rolled back — a split. The fix rolls
30+
// the configuration back together with the (already transactional) context and
31+
// effects.
32+
func TestFire_FailedEntryAction_RollsBackConfiguration(t *testing.T) {
33+
boom := errors.New("entry boom")
34+
bump := func(in state.AssignCtx[txnCtx]) txnCtx { c := in.Entity; c.Balance += 100; return c }
35+
fail := func(state.ActionCtx[txnCtx]) (state.Effect, error) { return nil, boom }
36+
37+
build := func() *state.Machine[string, string, txnCtx] {
38+
return state.Forge[string, string, txnCtx]("txn").
39+
Action("explode", fail).
40+
Reducer("bump", bump).
41+
State("off").
42+
Transition("off").On("go").GoTo("target").
43+
State("target").OnEntry("explode").OnEntryAssign("bump").
44+
Initial("off").
45+
CurrentStateFn(func(txnCtx) string { return "off" }).
46+
Quench()
47+
}
48+
49+
m := build()
50+
ctx := context.Background()
51+
52+
// A never-Fired control instance and its snapshot: the post-failure instance must
53+
// be indistinguishable from this one.
54+
control := m.Cast(txnCtx{Balance: 1}, state.WithInitialState("off"))
55+
wantSnap := control.Snapshot()
56+
57+
inst := m.Cast(txnCtx{Balance: 1}, state.WithInitialState("off"))
58+
59+
res := inst.Fire(ctx, "go")
60+
if res.Err == nil {
61+
t.Fatalf("expected the failed entry action to error, got nil (state=%v)", res.NewState)
62+
}
63+
if !errors.Is(res.Err, boom) {
64+
t.Fatalf("err = %v, want it to wrap %v", res.Err, boom)
65+
}
66+
67+
// (b) FireResult reports the actual resulting (original) state.
68+
if res.NewState != "off" {
69+
t.Fatalf("NewState = %v on a failed Fire; want the original state off (no half-advanced config)", res.NewState)
70+
}
71+
if got := inst.Current(); got != "off" {
72+
t.Fatalf("instance current = %v after a failed Fire; want off", got)
73+
}
74+
if cfg := inst.Configuration(); len(cfg) != 1 || cfg[0] != "off" {
75+
t.Fatalf("configuration = %v after a failed Fire; want [off]", cfg)
76+
}
77+
78+
// (c) Context unchanged: the entry assign that follows the failed action never
79+
// commits, and there is no separate rollback to verify since context was already
80+
// transactional — but assert it to lock the whole-instance no-op.
81+
if got := inst.Entity().Balance; got != 1 {
82+
t.Fatalf("context balance = %d after a failed Fire; want 1 (unchanged)", got)
83+
}
84+
85+
// (d) Snapshot the post-failure instance: it must equal a snapshot of the
86+
// never-Fired control, so nothing split was persisted.
87+
gotSnap := inst.Snapshot()
88+
if !reflect.DeepEqual(gotSnap, wantSnap) {
89+
t.Fatalf("post-failure snapshot != never-Fired snapshot:\n got = %+v\nwant = %+v", gotSnap, wantSnap)
90+
}
91+
92+
// The control instance can still Fire cleanly through a non-failing path, proving
93+
// the rolled-back instance is genuinely re-fireable from its original state.
94+
clean := build0(t)
95+
if r := clean.Fire(ctx, "go"); r.Err != nil {
96+
t.Fatalf("clean machine should advance: %v", r.Err)
97+
} else if r.NewState != "target" {
98+
t.Fatalf("clean machine NewState = %v, want target", r.NewState)
99+
}
100+
}
101+
102+
// build0 casts a no-fail variant of the txn machine for the re-fireability control.
103+
func build0(t *testing.T) *state.Instance[string, string, txnCtx] {
104+
t.Helper()
105+
m := state.Forge[string, string, txnCtx]("txn-ok").
106+
State("off").
107+
Transition("off").On("go").GoTo("target").
108+
State("target").
109+
Initial("off").
110+
CurrentStateFn(func(txnCtx) string { return "off" }).
111+
Quench()
112+
return m.Cast(txnCtx{Balance: 1}, state.WithInitialState("off"))
113+
}
114+
115+
// TestFire_FailedRegionEntry_RollsBackEarlierRegion covers the parallel-region
116+
// split: regions fire in declaration order within one macrostep, each committing its
117+
// own config/context fold to the live instance. When a LATER region's entry action
118+
// fails, an EARLIER region's already-committed transition must roll back too — a
119+
// partial-region commit is the parallel analog of the half-advanced config.
120+
//
121+
// r1 takes r1a -> r1b on "go" (committing a config swap and a context fold); r2 then
122+
// takes r2a -> r2b on "go" whose entry action errors. The whole Fire must be a no-op:
123+
// r1 stays at r1a and the r1 fold is discarded.
124+
func TestFire_FailedRegionEntry_RollsBackEarlierRegion(t *testing.T) {
125+
boom := errors.New("region entry boom")
126+
bump := func(in state.AssignCtx[txnCtx]) txnCtx { c := in.Entity; c.Balance += 10; return c }
127+
fail := func(state.ActionCtx[txnCtx]) (state.Effect, error) { return nil, boom }
128+
129+
m := state.Forge[string, string, txnCtx]("txn-par").
130+
Action("explode", fail).
131+
Reducer("bump", bump).
132+
SuperState("live").
133+
Region("r1").
134+
Initial("r1a").
135+
SubState("r1a").On("go").GoTo("r1b").Assign("bump").
136+
SubState("r1b").
137+
EndRegion().
138+
Region("r2").
139+
Initial("r2a").
140+
SubState("r2a").On("go").GoTo("r2b").
141+
SubState("r2b").OnEntry("explode").
142+
EndRegion().
143+
EndSuperState().
144+
Initial("live").
145+
Quench()
146+
147+
ctx := context.Background()
148+
control := m.Cast(txnCtx{Balance: 1}, state.WithInitialState("live"))
149+
wantSnap := control.Snapshot()
150+
151+
inst := m.Cast(txnCtx{Balance: 1}, state.WithInitialState("live"))
152+
res := inst.Fire(ctx, "go")
153+
if res.Err == nil {
154+
t.Fatalf("expected the failed region entry to error, got nil (state=%v)", res.NewState)
155+
}
156+
if !errors.Is(res.Err, boom) {
157+
t.Fatalf("err = %v, want it to wrap %v", res.Err, boom)
158+
}
159+
160+
// r1 must not have committed: configuration still holds r1a (and r2a), context
161+
// fold discarded.
162+
cfg := inst.Configuration()
163+
if !containsLeaf(cfg, "r1a") || containsLeaf(cfg, "r1b") {
164+
t.Fatalf("configuration = %v after a failed parallel Fire; r1 must stay at r1a", cfg)
165+
}
166+
if got := inst.Entity().Balance; got != 1 {
167+
t.Fatalf("context balance = %d after a failed parallel Fire; want 1 (r1 fold rolled back)", got)
168+
}
169+
170+
gotSnap := inst.Snapshot()
171+
if !reflect.DeepEqual(gotSnap, wantSnap) {
172+
t.Fatalf("post-failure parallel snapshot != never-Fired snapshot:\n got = %+v\nwant = %+v", gotSnap, wantSnap)
173+
}
174+
}
175+
176+
// containsLeaf reports whether leaf is present in cfg.
177+
func containsLeaf(cfg []string, leaf string) bool {
178+
for _, s := range cfg {
179+
if s == leaf {
180+
return true
181+
}
182+
}
183+
return false
184+
}

state/hardening_test.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,13 @@ func TestSelfTransition(t *testing.T) {
148148
}
149149
}
150150

151-
// TestActionError_AdvancesStateRecordsEffectError asserts the locked decision:
152-
// state advances before actions run; a failing action records OutcomeEffectError
153-
// and the typed *ActionFailedError, with the state already advanced.
154-
func TestActionError_AdvancesStateRecordsEffectError(t *testing.T) {
151+
// TestActionError_RollsBackRecordsEffectError asserts that a failing transition
152+
// action records OutcomeEffectError and the typed *ActionFailedError, and that the
153+
// failed Fire is transactional: the configuration rolls back to the source state
154+
// rather than leaking the half-advanced target. The configuration still advances in
155+
// place during the macrostep (so entry actions and the done cascade observe it), but
156+
// a failure discards that advance, so NewState reports the original state.
157+
func TestActionError_RollsBackRecordsEffectError(t *testing.T) {
155158
type s = int
156159
type e = int
157160
const a s = 0
@@ -178,8 +181,8 @@ func TestActionError_AdvancesStateRecordsEffectError(t *testing.T) {
178181
if !errors.Is(res.Err, boom) {
179182
t.Fatalf("err does not unwrap to boom: %v", res.Err)
180183
}
181-
if res.NewState != b {
182-
t.Fatalf("state = %v, want b (advanced before action)", res.NewState)
184+
if res.NewState != a {
185+
t.Fatalf("state = %v, want a (failed Fire rolls back the advanced configuration)", res.NewState)
183186
}
184187
if res.Trace.Outcome != state.OutcomeEffectError {
185188
t.Fatalf("outcome = %v, want OutcomeEffectError", res.Trace.Outcome)

0 commit comments

Comments
 (0)