From 6c0a400c10e62005bb69cb60f92184b45cf3f619 Mon Sep 17 00:00:00 2001 From: Morten Hersson Date: Fri, 12 Dec 2025 13:32:54 +0100 Subject: [PATCH] feat(error-events): Add error event transitions for composite states - Add ErrorTransition field to StateConfig for declarative error handling - Implement automatic error event triggering when actions fail - Update UML parser to recognize 'error' keyword as special event - Add transitionIndex template function to correctly map non-event transitions - Generate error transition checks in composite and regular state execution - Create comprehensive error-events example with order processor - Document error event syntax and benefits in vectorsigma-uml-syntax.md - Add unit tests for error event parsing and handling - Maintain backward compatibility with existing IsError guards --- .../internal/fsm/zz_generated_statemachine.go | 23 +- .../golden/fsm/zz_generated_statemachine.go | 23 +- .../golden/fsm/zz_generated_statemachine.go | 23 +- docs/vectorsigma-uml-syntax.md | 104 ++++++ examples/error-events/README.md | 166 +++++++++ .../error-events/orderprocessor/actions.go | 100 ++++++ .../orderprocessor/actions_test.go | 253 ++++++++++++++ .../orderprocessor/extendedstate.go | 19 + .../error-events/orderprocessor/guards.go | 1 + .../orderprocessor/guards_test.go | 1 + .../orderprocessor/statemachine_test.go | 103 ++++++ .../zz_generated_statemachine.go | 330 ++++++++++++++++++ .../zz_generated_statemachine_test.go | 29 ++ examples/error-events/statemachine.md | 75 ++++ .../statemachine/zz_generated_statemachine.go | 23 +- pkgs/generator/generator.go | 21 +- .../application/statemachine.go.tmpl | 35 +- .../templates/operator/statemachine.go.tmpl | 35 +- pkgs/uml/uml.go | 13 +- pkgs/uml/uml_test.go | 142 ++++++++ 20 files changed, 1486 insertions(+), 33 deletions(-) create mode 100644 examples/error-events/README.md create mode 100644 examples/error-events/orderprocessor/actions.go create mode 100644 examples/error-events/orderprocessor/actions_test.go create mode 100644 examples/error-events/orderprocessor/extendedstate.go create mode 100644 examples/error-events/orderprocessor/guards.go create mode 100644 examples/error-events/orderprocessor/guards_test.go create mode 100644 examples/error-events/orderprocessor/statemachine_test.go create mode 100644 examples/error-events/orderprocessor/zz_generated_statemachine.go create mode 100644 examples/error-events/orderprocessor/zz_generated_statemachine_test.go create mode 100644 examples/error-events/statemachine.md diff --git a/cmd/testdata/new_module/golden/internal/fsm/zz_generated_statemachine.go b/cmd/testdata/new_module/golden/internal/fsm/zz_generated_statemachine.go index 5fbfabf..73d7aaa 100644 --- a/cmd/testdata/new_module/golden/internal/fsm/zz_generated_statemachine.go +++ b/cmd/testdata/new_module/golden/internal/fsm/zz_generated_statemachine.go @@ -49,10 +49,11 @@ type Guard struct { // StateConfig holds the actions and guards for a state. type StateConfig struct { - Actions []Action - Guards []Guard - Transitions map[int]StateName // Maps guard index to the next state - Composite CompositeState + Actions []Action + Guards []Guard + Transitions map[int]StateName // Maps guard index to the next state + Composite CompositeState + ErrorTransition StateName // Target state for error events (empty if not defined) } type CompositeState struct { @@ -179,6 +180,13 @@ func run(fsm *TrafficLight, stateConfigs map[StateName]StateConfig, depth int) e if err != nil { fsm.Context.Logger.Error("composite state machine failed", "state", fsm.CurrentState, "error", err) fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this composite state + if config.ErrorTransition != "" { + fsm.Context.Logger.Debug("error event triggered", "from", parentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } } fsm.Context.Logger.Debug("exiting composite state", "state", parentState) @@ -189,6 +197,13 @@ func run(fsm *TrafficLight, stateConfigs map[StateName]StateConfig, depth int) e if err != nil { fsm.Context.Logger.Error("action failed", "state", fsm.CurrentState, "error", err) fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this state + if config.ErrorTransition != "" { + fsm.Context.Logger.Debug("error event triggered", "from", fsm.CurrentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } } } diff --git a/cmd/testdata/operator/golden/fsm/zz_generated_statemachine.go b/cmd/testdata/operator/golden/fsm/zz_generated_statemachine.go index 023733a..ee0df22 100644 --- a/cmd/testdata/operator/golden/fsm/zz_generated_statemachine.go +++ b/cmd/testdata/operator/golden/fsm/zz_generated_statemachine.go @@ -54,10 +54,11 @@ type Guard struct { // StateConfig holds the actions and guards for a state. type StateConfig struct { - Actions []Action - Guards []Guard - Transitions map[int]StateName // Maps guard index to the next state - Composite CompositeState + Actions []Action + Guards []Guard + Transitions map[int]StateName // Maps guard index to the next state + Composite CompositeState + ErrorTransition StateName // Target state for error events (empty if not defined) } type CompositeState struct { @@ -174,6 +175,13 @@ func run(fsm *Testreconcileloop, stateConfigs map[StateName]StateConfig, depth i if err != nil { fsm.Context.Logger.Error(err, "composite state machine failed", "state", fsm.CurrentState) fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this composite state + if config.ErrorTransition != "" { + fsm.Context.Logger.V(1).Info("error event triggered", "from", parentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } } fsm.Context.Logger.V(1).Info("exiting composite state", "state", parentState) @@ -184,6 +192,13 @@ func run(fsm *Testreconcileloop, stateConfigs map[StateName]StateConfig, depth i if err != nil { fsm.Context.Logger.Error(err, "action failed", "state", fsm.CurrentState) fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this state + if config.ErrorTransition != "" { + fsm.Context.Logger.V(1).Info("error event triggered", "from", fsm.CurrentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } } } diff --git a/cmd/testdata/package/golden/fsm/zz_generated_statemachine.go b/cmd/testdata/package/golden/fsm/zz_generated_statemachine.go index 5fbfabf..73d7aaa 100644 --- a/cmd/testdata/package/golden/fsm/zz_generated_statemachine.go +++ b/cmd/testdata/package/golden/fsm/zz_generated_statemachine.go @@ -49,10 +49,11 @@ type Guard struct { // StateConfig holds the actions and guards for a state. type StateConfig struct { - Actions []Action - Guards []Guard - Transitions map[int]StateName // Maps guard index to the next state - Composite CompositeState + Actions []Action + Guards []Guard + Transitions map[int]StateName // Maps guard index to the next state + Composite CompositeState + ErrorTransition StateName // Target state for error events (empty if not defined) } type CompositeState struct { @@ -179,6 +180,13 @@ func run(fsm *TrafficLight, stateConfigs map[StateName]StateConfig, depth int) e if err != nil { fsm.Context.Logger.Error("composite state machine failed", "state", fsm.CurrentState, "error", err) fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this composite state + if config.ErrorTransition != "" { + fsm.Context.Logger.Debug("error event triggered", "from", parentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } } fsm.Context.Logger.Debug("exiting composite state", "state", parentState) @@ -189,6 +197,13 @@ func run(fsm *TrafficLight, stateConfigs map[StateName]StateConfig, depth int) e if err != nil { fsm.Context.Logger.Error("action failed", "state", fsm.CurrentState, "error", err) fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this state + if config.ErrorTransition != "" { + fsm.Context.Logger.Debug("error event triggered", "from", fsm.CurrentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } } } diff --git a/docs/vectorsigma-uml-syntax.md b/docs/vectorsigma-uml-syntax.md index 489c8cf..8ba66d6 100644 --- a/docs/vectorsigma-uml-syntax.md +++ b/docs/vectorsigma-uml-syntax.md @@ -26,6 +26,13 @@ transitions. - [6. Transitions](#6-transitions) - [7. Composite States](#7-composite-states) - [7.1 Defining Composite States](#71-defining-composite-states) + - [7.2 Error Events](#72-error-events) + - [7.2.1 Error Event Syntax](#721-error-event-syntax) + - [7.2.2 Benefits of Error Events](#722-benefits-of-error-events) + - [7.2.3 Traditional vs Error Event Approach](#723-traditional-vs-error-event-approach) + - [7.2.4 Behavior](#724-behavior) + - [7.2.5 Example](#725-example) + - [7.2.6 Backward Compatibility](#726-backward-compatibility) - [8. Notes](#8-notes) @@ -348,6 +355,103 @@ CompositeState -[bold]-> StateA In this example, `CompositeState` contains two nested states: `NestedState1` and `NestedState2`. +### 7.2 Error Events + +VectorSigma supports error events for composite states, allowing you to define +error transitions that are automatically triggered when any action within the +composite state returns an error. + +#### 7.2.1 Error Event Syntax + +To define an error transition, use the `error` keyword: + +```plantuml +state CompositeStat { + [*] --> SubState1 + SubState1: do / Action1 + SubState1 --> SubState2 + SubState2: do / Action2 + SubState2 --> [*] +} + +CompositeState --> ErrorHandling : error +``` + +In this example, if either `Action1` or `Action2` returns an error, the state +machine will automatically transition from `CompositeState` to `ErrorHandling`. + +#### 7.2.2 Benefits of Error Events + +Error events provide several advantages over traditional `IsError` guard +approaches: + +- **Cleaner diagrams**: No need for repetitive `IsError` guards on every state + within a composite state +- **Centralized error handling**: Define error transitions once at the composite + level +- **Declarative error handling**: Express error flows as events in your state + diagram +- **Automatic triggering**: Errors automatically trigger the transition without + explicit guard checks + +#### 7.2.3 Traditional vs Error Event Approach + +**Traditional approach with IsError guards:** + +```plantuml +state Initializing { + InitContext: do / InitializeContext + InitContext --> [*] : IsError + InitContext --> LoadData + + LoadData: do / LoadDataFromDB + LoadData --> [*] : IsError + LoadData --> [*] +} +``` + +**Error event approach:** + +```plantuml +state Initializing { + InitContext: do / InitializeContext + InitContext --> LoadData + + LoadData: do / LoadDataFromDB + LoadData --> [*] +} + +Initializing --> ErrorHandling : error +``` + +#### 7.2.4 Behavior + +When an action within a composite state returns an error: + +1. The error is stored in `ExtendedState.Error` +2. If an error transition is defined for the composite state, the state machine + transitions to the target state +3. If no error transition is defined, the behavior falls back to the traditional + approach (error stored in ExtendedState, guards can check it) + +**Important**: All actions within a composite state execute sequentially, even +if earlier actions fail. The error transition is only evaluated after the +composite state completes. + +#### 7.2.5 Example + +See the [error-events example](../examples/error-events/) for a complete working +implementation demonstrating error event handling in an order processing state +machine. + +#### 7.2.6 Backward Compatibility + +Error events are fully backward compatible: + +- Existing `IsError` guards continue to work as before +- States without error transitions behave unchanged +- You can mix error events and `IsError` guards in the same state machine + ## 8. Notes The UML diagram may contain notes that provide additional context or diff --git a/examples/error-events/README.md b/examples/error-events/README.md new file mode 100644 index 0000000..e6a1dc2 --- /dev/null +++ b/examples/error-events/README.md @@ -0,0 +1,166 @@ +# Error Events Example + +This example demonstrates the error event feature in VectorSigma, which provides +automatic error handling for composite states without requiring explicit +`IsError` guard checks. + +## Overview + +Error events allow you to define error transitions at the composite state level. +When any action within a composite state returns an error, the error event is +automatically triggered, transitioning to the specified error handling state. + +## Benefits + +1. **Cleaner UML diagrams**: No need for repetitive `IsError` guards on every + state +2. **Centralized error handling**: Define error transitions once at the + composite level +3. **Declarative error handling**: Express error flows as events in your state + diagram +4. **Backward compatible**: Existing `IsError` guards continue to work + +## How It Works + +### Traditional Approach (IsError Guards) + +```plantuml +state Initializing { + InitializingContext: do / InitializeContext + InitializingContext --> [*] : IsError + InitializingContext --> LoadingSubject + + LoadingSubject: do / LoadSubject + LoadingSubject --> [*] : IsError + LoadingSubject --> [*] +} +``` + +### Error Event Approach + +```plantuml +state Initializing { + InitializingContext: do / InitializeContext + InitializingContext --> LoadingSubject + + LoadingSubject: do / LoadSubject + LoadingSubject --> [*] +} + +Initializing --> ErrorHandling : error +``` + +## Example State Machine + +The order processor example demonstrates a typical e-commerce order flow with +error handling: + +1. **Initializing** - Initialize context and load order information +2. **Processing** - Validate order, charge payment, and fulfill +3. **ErrorHandling** - Handle any errors that occur +4. **Completed** - Final state + +### UML Diagram + +```plantuml +@startuml + +title OrderProcessor + +[*] --> Initializing + +state Initializing { + [*] --> InitializingContext + InitializingContext: do / InitializeContext + InitializingContext --> LoadingSubject + LoadingSubject: do / LoadSubject + LoadingSubject --> [*] +} + +Initializing --> ErrorHandling : error +Initializing --> Processing + +state Processing { + [*] --> ValidatingOrder + ValidatingOrder: do / ValidateOrder + ValidatingOrder --> ChargingPayment + ChargingPayment: do / ChargePayment + ChargingPayment --> FulfillingOrder + FulfillingOrder: do / FulfillOrder + FulfillingOrder --> [*] +} + +Processing --> ErrorHandling : error +Processing --> Completed + +ErrorHandling: do / HandleError +ErrorHandling --> Completed + +Completed --> [*] + +@enduml +``` + +## Running the Example + +```bash +# Run all tests +go test -v + +# Run specific test +go test -run TestOrderProcessor_PaymentFailureTriggersErrorEvent -v + +# Run benchmarks +go test -bench=. -benchmem +``` + +## Test Cases + +1. **TestOrderProcessor_SuccessfulOrder** - Tests the happy path with no errors +2. **TestOrderProcessor_PaymentFailureTriggersErrorEvent** - Tests error + handling when payment fails +3. **TestOrderProcessor_InitializationErrorTriggersErrorEvent** - Tests error + handling during initialization + +## Implementation Details + +### Error Event Syntax + +In your UML diagram, use the `error` keyword to define error transitions: + +```plantuml +CompositState --> ErrorState : error +``` + +### Generated Code + +VectorSigma generates: + +1. An `ErrorTransition` field in `StateConfig` +2. Runtime checks for error transitions after composite state execution +3. Automatic transition to the error target when errors occur + +### Behavior + +- Errors are checked after a composite state completes +- If an `ErrorTransition` is defined, the state machine transitions to that + state +- If no `ErrorTransition` is defined, the error is stored in + `ExtendedState.Error` (backward compatible) +- All actions within a composite state run even if one fails (errors accumulate) + +## Best Practices + +1. Define error transitions at the composite state level for cleaner diagrams +2. Use error events for expected error conditions +3. Implement centralized error handling states +4. Initialize the `ExtendedState.Data` map in error handlers (it may be nil) +5. Log errors appropriately for debugging + +## Backward Compatibility + +The error event feature is fully backward compatible: + +- Existing `IsError` guards continue to work +- States without error transitions behave as before +- You can mix error events and `IsError` guards in the same state machine diff --git a/examples/error-events/orderprocessor/actions.go b/examples/error-events/orderprocessor/actions.go new file mode 100644 index 0000000..9ff6460 --- /dev/null +++ b/examples/error-events/orderprocessor/actions.go @@ -0,0 +1,100 @@ +package orderprocessor + +import ( + "errors" + "fmt" +) + +// InitializeContext initializes the order context. +func (fsm *OrderProcessor) InitializeContextAction(_ ...string) error { + fsm.Context.Logger.Info("initializing context") + + // Simulate initialization logic + if fsm.ExtendedState.Data == nil { + fsm.ExtendedState.Data = make(map[string]interface{}) + } + + fsm.ExtendedState.Data["initialized"] = true + + return nil +} + +// LoadSubject loads order subject information. +func (fsm *OrderProcessor) LoadSubjectAction(_ ...string) error { + fsm.Context.Logger.Info("loading subject") + + // Check if context was initialized + if fsm.ExtendedState.Data["initialized"] != true { + return errors.New("context not initialized") + } + + fsm.ExtendedState.Data["subject"] = "Order #12345" + + return nil +} + +// ValidateOrder validates the order. +func (fsm *OrderProcessor) ValidateOrderAction(_ ...string) error { + fsm.Context.Logger.Info("validating order") + + subject, ok := fsm.ExtendedState.Data["subject"].(string) + if !ok { + return errors.New("no subject loaded") + } + + fsm.Context.Logger.Info("order validated", "subject", subject) + fsm.ExtendedState.Data["validated"] = true + + return nil +} + +// ChargePayment processes payment. +func (fsm *OrderProcessor) ChargePaymentAction(_ ...string) error { + fsm.Context.Logger.Info("charging payment") + + if fsm.ExtendedState.Data["validated"] != true { + return errors.New("order not validated") + } + + // Simulate a payment failure to test error handling + if fsm.ExtendedState.Data["simulate_payment_error"] == true { + return errors.New("payment declined") + } + + fsm.ExtendedState.Data["payment_charged"] = true + + return nil +} + +// FulfillOrder fulfills the order. +func (fsm *OrderProcessor) FulfillOrderAction(_ ...string) error { + fsm.Context.Logger.Info("fulfilling order") + + if fsm.ExtendedState.Data["payment_charged"] != true { + return errors.New("payment not charged") + } + + fsm.ExtendedState.Data["fulfilled"] = true + + return nil +} + +// HandleError handles errors. +func (fsm *OrderProcessor) HandleErrorAction(_ ...string) error { + fsm.Context.Logger.Error("handling error", "error", fsm.ExtendedState.Error) + + // Initialize Data map if needed + if fsm.ExtendedState.Data == nil { + fsm.ExtendedState.Data = make(map[string]interface{}) + } + + // Log error details + if fsm.ExtendedState.Error != nil { + fmt.Printf("Error occurred: %v\n", fsm.ExtendedState.Error) + } + + // Mark error as handled + fsm.ExtendedState.Data["error_handled"] = true + + return nil +} diff --git a/examples/error-events/orderprocessor/actions_test.go b/examples/error-events/orderprocessor/actions_test.go new file mode 100644 index 0000000..ac6620a --- /dev/null +++ b/examples/error-events/orderprocessor/actions_test.go @@ -0,0 +1,253 @@ +package orderprocessor_test + +import ( + "testing" + + "github.com/mhersson/vectorsigma/examples/error-events/orderprocessor" +) + +// +vectorsigma:action:ChargePayment +func TestOrderProcessor_ChargePaymentAction(t *testing.T) { + type fields struct { + context *orderprocessor.Context + currentState orderprocessor.StateName + stateConfigs map[orderprocessor.StateName]orderprocessor.StateConfig + ExtendedState *orderprocessor.ExtendedState + } + + type args struct { + params []string + } + + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + + t.Parallel() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fsm := &orderprocessor.OrderProcessor{ + Context: tt.fields.context, + CurrentState: tt.fields.currentState, + StateConfigs: tt.fields.stateConfigs, + ExtendedState: tt.fields.ExtendedState, + } + if err := fsm.ChargePaymentAction(tt.args.params...); (err != nil) != tt.wantErr { + t.Errorf("OrderProcessor.ChargePaymentAction() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// +vectorsigma:action:FulfillOrder +func TestOrderProcessor_FulfillOrderAction(t *testing.T) { + type fields struct { + context *orderprocessor.Context + currentState orderprocessor.StateName + stateConfigs map[orderprocessor.StateName]orderprocessor.StateConfig + ExtendedState *orderprocessor.ExtendedState + } + + type args struct { + params []string + } + + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + + t.Parallel() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fsm := &orderprocessor.OrderProcessor{ + Context: tt.fields.context, + CurrentState: tt.fields.currentState, + StateConfigs: tt.fields.stateConfigs, + ExtendedState: tt.fields.ExtendedState, + } + if err := fsm.FulfillOrderAction(tt.args.params...); (err != nil) != tt.wantErr { + t.Errorf("OrderProcessor.FulfillOrderAction() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// +vectorsigma:action:HandleError +func TestOrderProcessor_HandleErrorAction(t *testing.T) { + type fields struct { + context *orderprocessor.Context + currentState orderprocessor.StateName + stateConfigs map[orderprocessor.StateName]orderprocessor.StateConfig + ExtendedState *orderprocessor.ExtendedState + } + + type args struct { + params []string + } + + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + + t.Parallel() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fsm := &orderprocessor.OrderProcessor{ + Context: tt.fields.context, + CurrentState: tt.fields.currentState, + StateConfigs: tt.fields.stateConfigs, + ExtendedState: tt.fields.ExtendedState, + } + if err := fsm.HandleErrorAction(tt.args.params...); (err != nil) != tt.wantErr { + t.Errorf("OrderProcessor.HandleErrorAction() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// +vectorsigma:action:InitializeContext +func TestOrderProcessor_InitializeContextAction(t *testing.T) { + type fields struct { + context *orderprocessor.Context + currentState orderprocessor.StateName + stateConfigs map[orderprocessor.StateName]orderprocessor.StateConfig + ExtendedState *orderprocessor.ExtendedState + } + + type args struct { + params []string + } + + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + + t.Parallel() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fsm := &orderprocessor.OrderProcessor{ + Context: tt.fields.context, + CurrentState: tt.fields.currentState, + StateConfigs: tt.fields.stateConfigs, + ExtendedState: tt.fields.ExtendedState, + } + if err := fsm.InitializeContextAction(tt.args.params...); (err != nil) != tt.wantErr { + t.Errorf("OrderProcessor.InitializeContextAction() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// +vectorsigma:action:LoadSubject +func TestOrderProcessor_LoadSubjectAction(t *testing.T) { + type fields struct { + context *orderprocessor.Context + currentState orderprocessor.StateName + stateConfigs map[orderprocessor.StateName]orderprocessor.StateConfig + ExtendedState *orderprocessor.ExtendedState + } + + type args struct { + params []string + } + + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + + t.Parallel() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fsm := &orderprocessor.OrderProcessor{ + Context: tt.fields.context, + CurrentState: tt.fields.currentState, + StateConfigs: tt.fields.stateConfigs, + ExtendedState: tt.fields.ExtendedState, + } + if err := fsm.LoadSubjectAction(tt.args.params...); (err != nil) != tt.wantErr { + t.Errorf("OrderProcessor.LoadSubjectAction() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +// +vectorsigma:action:ValidateOrder +func TestOrderProcessor_ValidateOrderAction(t *testing.T) { + type fields struct { + context *orderprocessor.Context + currentState orderprocessor.StateName + stateConfigs map[orderprocessor.StateName]orderprocessor.StateConfig + ExtendedState *orderprocessor.ExtendedState + } + + type args struct { + params []string + } + + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + + t.Parallel() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fsm := &orderprocessor.OrderProcessor{ + Context: tt.fields.context, + CurrentState: tt.fields.currentState, + StateConfigs: tt.fields.stateConfigs, + ExtendedState: tt.fields.ExtendedState, + } + if err := fsm.ValidateOrderAction(tt.args.params...); (err != nil) != tt.wantErr { + t.Errorf("OrderProcessor.ValidateOrderAction() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/examples/error-events/orderprocessor/extendedstate.go b/examples/error-events/orderprocessor/extendedstate.go new file mode 100644 index 0000000..fc8ce1e --- /dev/null +++ b/examples/error-events/orderprocessor/extendedstate.go @@ -0,0 +1,19 @@ +package orderprocessor + +import ( + "log/slog" +) + +// A struct that holds the items needed for the actions to do their work. +// Things like client libraries and loggers, go here. +type Context struct { + Logger *slog.Logger // Do NOT delete this! +} + +// A struct that holds the "extended state" of the state machine, including data +// being fetched and read. This should only be modified by actions, while guards +// should only read the extended state to assess their value. +type ExtendedState struct { + Error error + Data map[string]interface{} // Custom data storage for the order processor +} diff --git a/examples/error-events/orderprocessor/guards.go b/examples/error-events/orderprocessor/guards.go new file mode 100644 index 0000000..fdd5b53 --- /dev/null +++ b/examples/error-events/orderprocessor/guards.go @@ -0,0 +1 @@ +package orderprocessor diff --git a/examples/error-events/orderprocessor/guards_test.go b/examples/error-events/orderprocessor/guards_test.go new file mode 100644 index 0000000..c999d2a --- /dev/null +++ b/examples/error-events/orderprocessor/guards_test.go @@ -0,0 +1 @@ +package orderprocessor_test diff --git a/examples/error-events/orderprocessor/statemachine_test.go b/examples/error-events/orderprocessor/statemachine_test.go new file mode 100644 index 0000000..9169b5a --- /dev/null +++ b/examples/error-events/orderprocessor/statemachine_test.go @@ -0,0 +1,103 @@ +package orderprocessor + +import ( + "testing" +) + +func TestOrderProcessor_SuccessfulOrder(t *testing.T) { + fsm := New() + + err := fsm.Run() + if err != nil { + t.Errorf("expected no error, got %v", err) + } + + if fsm.ExtendedState.Data["fulfilled"] != true { + t.Error("expected order to be fulfilled") + } + + if fsm.ExtendedState.Data["error_handled"] == true { + t.Error("error handler should not have been called") + } +} + +func TestOrderProcessor_PaymentFailureTriggersErrorEvent(t *testing.T) { + fsm := New() + + // Simulate a payment failure + fsm.ExtendedState.Data = make(map[string]interface{}) + fsm.ExtendedState.Data["simulate_payment_error"] = true + + err := fsm.Run() + + // The error should be stored in ExtendedState but Run() should complete + if err == nil { + t.Error("expected error to be returned") + } + + // Verify error was handled + if fsm.ExtendedState.Data["error_handled"] != true { + t.Error("error handler should have been called via error event") + } + + // Note: In composite states, all actions run even if one fails. + // The error is only propagated after the composite state completes. + // Since FulfillOrder runs after ChargePayment and also fails (payment not charged), + // the final error will be from FulfillOrder, not ChargePayment. + + // Verify the error was captured + if fsm.ExtendedState.Error == nil { + t.Error("expected error to be stored in ExtendedState") + } +} + +func TestOrderProcessor_InitializationErrorTriggersErrorEvent(t *testing.T) { + fsm := New() + + // Force an initialization error by clearing the data map after initialization + // This will cause LoadSubject to fail + originalInitAction := fsm.StateConfigs[Initializing].Composite.StateConfigs[InitializingContext].Actions[0].Execute + + fsm.StateConfigs[Initializing].Composite.StateConfigs[InitializingContext].Actions[0].Execute = func(params ...string) error { + // Don't initialize the data map + return nil + } + + // Now LoadSubject will fail because it checks for initialized flag + err := fsm.Run() + + // Restore original action + fsm.StateConfigs[Initializing].Composite.StateConfigs[InitializingContext].Actions[0].Execute = originalInitAction + + if err == nil { + t.Error("expected error to be returned") + } + + // Verify error was handled + if fsm.ExtendedState.Data["error_handled"] != true { + t.Error("error handler should have been called via error event") + } + + // Verify we didn't reach processing + if fsm.ExtendedState.Data["validated"] == true { + t.Error("should not have reached validation after initialization error") + } +} + +// BenchmarkOrderProcessor_SuccessfulFlow benchmarks the successful order flow. +func BenchmarkOrderProcessor_SuccessfulFlow(b *testing.B) { + for i := 0; i < b.N; i++ { + fsm := New() + _ = fsm.Run() + } +} + +// BenchmarkOrderProcessor_ErrorHandling benchmarks the error handling flow. +func BenchmarkOrderProcessor_ErrorHandling(b *testing.B) { + for i := 0; i < b.N; i++ { + fsm := New() + fsm.ExtendedState.Data = make(map[string]interface{}) + fsm.ExtendedState.Data["simulate_payment_error"] = true + _ = fsm.Run() + } +} diff --git a/examples/error-events/orderprocessor/zz_generated_statemachine.go b/examples/error-events/orderprocessor/zz_generated_statemachine.go new file mode 100644 index 0000000..5dec835 --- /dev/null +++ b/examples/error-events/orderprocessor/zz_generated_statemachine.go @@ -0,0 +1,330 @@ +// This file is generated by VectorSigma . DO NOT EDIT. +package orderprocessor + +import ( + "fmt" + "log/slog" + "os" +) + +type ( + StateName string + ActionName string + GuardName string +) + +const ( + ChargingPayment StateName = "ChargingPayment" + Completed StateName = "Completed" + ErrorHandling StateName = "ErrorHandling" + FinalState StateName = "FinalState" + FulfillingOrder StateName = "FulfillingOrder" + InitialState StateName = "InitialState" + Initializing StateName = "Initializing" + InitializingContext StateName = "InitializingContext" + LoadingSubject StateName = "LoadingSubject" + Processing StateName = "Processing" + ValidatingOrder StateName = "ValidatingOrder" +) + +const ( + ChargePayment ActionName = "ChargePayment" + FulfillOrder ActionName = "FulfillOrder" + HandleError ActionName = "HandleError" + InitializeContext ActionName = "InitializeContext" + LoadSubject ActionName = "LoadSubject" + ValidateOrder ActionName = "ValidateOrder" +) + +const () + +const maxStateDepth = 5 + +// Action represents a function that can be executed in a state and may return an error. +type Action struct { + Name ActionName + Params []string + Execute func(...string) error +} + +// Guard represents a function that returns a boolean indicating if a transition should occur. +type Guard struct { + Name GuardName + Params []string + Check func(...string) bool + Action *Action +} + +// StateConfig holds the actions and guards for a state. +type StateConfig struct { + Actions []Action + Guards []Guard + Transitions map[int]StateName // Maps guard index to the next state + Composite CompositeState + ErrorTransition StateName // Target state for error events (empty if not defined) +} + +type CompositeState struct { + InitialState StateName + StateConfigs map[StateName]StateConfig +} + +// VectorSigma represents the Finite State Machine (fsm) for VectorSigma. +type OrderProcessor struct { + Context *Context + CurrentState StateName + ExtendedState *ExtendedState + StateConfigs map[StateName]StateConfig +} + +// New initializes a new FSM. +func New() *OrderProcessor { + logLevel := new(slog.LevelVar) + logLevel.Set(slog.LevelInfo) + + if os.Getenv("ORDERPROCESSOR_DEBUG") != "" { + logLevel.Set(slog.LevelDebug) + } + + fsm := &OrderProcessor{ + Context: &Context{Logger: slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel}))}, + CurrentState: InitialState, + ExtendedState: &ExtendedState{}, + StateConfigs: make(map[StateName]StateConfig), + } + fsm.StateConfigs[Completed] = StateConfig{ + Actions: []Action{}, + Guards: []Guard{}, + Transitions: map[int]StateName{ + 0: FinalState, + }, + } + fsm.StateConfigs[ErrorHandling] = StateConfig{ + Actions: []Action{ + {Name: HandleError, Execute: fsm.HandleErrorAction, Params: []string{}}, + }, + Guards: []Guard{}, + Transitions: map[int]StateName{ + 0: Completed, + }, + } + + fsm.StateConfigs[InitialState] = StateConfig{ + Actions: []Action{}, + Guards: []Guard{}, + Transitions: map[int]StateName{ + 0: Initializing, + }, + } + fsm.StateConfigs[Initializing] = StateConfig{ + Actions: []Action{}, + Guards: []Guard{}, + Transitions: map[int]StateName{ + 0: Processing, + }, + Composite: CompositeState{ + InitialState: InitialState, + StateConfigs: map[StateName]StateConfig{ + + InitialState: { + Actions: []Action{}, + Guards: []Guard{}, + Transitions: map[int]StateName{ + 0: InitializingContext, + }, + }, + InitializingContext: { + Actions: []Action{ + {Name: InitializeContext, Execute: fsm.InitializeContextAction, Params: []string{}}, + }, + Guards: []Guard{}, + Transitions: map[int]StateName{ + 0: LoadingSubject, + }, + }, + LoadingSubject: { + Actions: []Action{ + {Name: LoadSubject, Execute: fsm.LoadSubjectAction, Params: []string{}}, + }, + Guards: []Guard{}, + Transitions: map[int]StateName{ + 0: FinalState, + }, + }, + }, + }, + ErrorTransition: ErrorHandling, + } + fsm.StateConfigs[Processing] = StateConfig{ + Actions: []Action{}, + Guards: []Guard{}, + Transitions: map[int]StateName{ + 0: Completed, + }, + Composite: CompositeState{ + InitialState: InitialState, + StateConfigs: map[StateName]StateConfig{ + ChargingPayment: { + Actions: []Action{ + {Name: ChargePayment, Execute: fsm.ChargePaymentAction, Params: []string{}}, + }, + Guards: []Guard{}, + Transitions: map[int]StateName{ + 0: FulfillingOrder, + }, + }, + + FulfillingOrder: { + Actions: []Action{ + {Name: FulfillOrder, Execute: fsm.FulfillOrderAction, Params: []string{}}, + }, + Guards: []Guard{}, + Transitions: map[int]StateName{ + 0: FinalState, + }, + }, + InitialState: { + Actions: []Action{}, + Guards: []Guard{}, + Transitions: map[int]StateName{ + 0: ValidatingOrder, + }, + }, + ValidatingOrder: { + Actions: []Action{ + {Name: ValidateOrder, Execute: fsm.ValidateOrderAction, Params: []string{}}, + }, + Guards: []Guard{}, + Transitions: map[int]StateName{ + 0: ChargingPayment, + }, + }, + }, + }, + ErrorTransition: ErrorHandling, + } + + return fsm +} + +// Run handles the state transitions based on the current state. +func (fsm *OrderProcessor) Run() error { + return run(fsm, fsm.StateConfigs, 0) +} + +func run(fsm *OrderProcessor, stateConfigs map[StateName]StateConfig, depth int) error { + if depth > maxStateDepth { + return fmt.Errorf("max state depth exceeded") + } + + for { + // If we are in the FinalState, exit the FSM + if fsm.CurrentState == FinalState { + // Reset to the Initial State in case the FSM is run in a loop + fsm.CurrentState = InitialState + + return fsm.ExtendedState.Error + } + + config, exists := stateConfigs[fsm.CurrentState] + + if !exists { + fsm.Context.Logger.Error("missing config", "state", fsm.CurrentState) + + return fmt.Errorf("missing config for state: %s", fsm.CurrentState) + } + + if config.Composite.StateConfigs != nil { + parentState := fsm.CurrentState + // Recursively run the composite state machine + fsm.CurrentState = config.Composite.InitialState + fsm.Context.Logger.Debug("entering composite state", "state", parentState, "initial", fsm.CurrentState) + err := run(fsm, config.Composite.StateConfigs, depth+1) + if err != nil { + fsm.Context.Logger.Error("composite state machine failed", "state", fsm.CurrentState, "error", err) + fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this composite state + if config.ErrorTransition != "" { + fsm.Context.Logger.Debug("error event triggered", "from", parentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } + } + + fsm.Context.Logger.Debug("exiting composite state", "state", parentState) + fsm.CurrentState = parentState + } else { + // Execute all actions for the current state + err := runAllActions(fsm.Context, fsm.CurrentState, config.Actions) + if err != nil { + fsm.Context.Logger.Error("action failed", "state", fsm.CurrentState, "error", err) + fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this state + if config.ErrorTransition != "" { + fsm.Context.Logger.Debug("error event triggered", "from", fsm.CurrentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } + } + } + + // Check guards and determine the next state + nextState, err := runAllGuards(fsm.Context, fsm.CurrentState, config) + if err != nil { + // Guarded actions will always transition to the FinalState + fsm.ExtendedState.Error = err + fsm.CurrentState = FinalState + } + if nextState != "" { + fsm.CurrentState = nextState + + continue + } + + // Check for unguarded transition + if nextState, exists := config.Transitions[len(config.Guards)]; exists { + fsm.Context.Logger.Debug("unguarded transition", "current", fsm.CurrentState, "next", nextState) + fsm.CurrentState = nextState + } + } + +} + +func runAllActions(context *Context, currentState StateName, actions []Action) error { + for _, action := range actions { + context.Logger.Debug("executing", "action", action.Name, "state", currentState) + + if err := action.Execute(action.Params...); err != nil { + return err + } + } + + return nil +} + +func runAllGuards(context *Context, currentState StateName, config StateConfig) (StateName, error) { + for guardIndex, guard := range config.Guards { + if guard.Check(guard.Params...) { + if guard.Action != nil { + action := guard.Action + if err := action.Execute(action.Params...); err != nil { + context.Logger.Debug("guarded action failed", "state", currentState, + "guard", guard.Name, "action", action.Name, "error", err) + + return "", err + } + } + + // Transition to the state mapped to this guard index + if nextState, exists := config.Transitions[guardIndex]; exists { + context.Logger.Debug("guarded transition", "guard", guard.Name, "current", currentState, "next", nextState) + + return nextState, nil + } + } + } + + return "", nil +} diff --git a/examples/error-events/orderprocessor/zz_generated_statemachine_test.go b/examples/error-events/orderprocessor/zz_generated_statemachine_test.go new file mode 100644 index 0000000..e89a674 --- /dev/null +++ b/examples/error-events/orderprocessor/zz_generated_statemachine_test.go @@ -0,0 +1,29 @@ +// This file is generated by VectorSigma . DO NOT EDIT. +package orderprocessor_test + +import ( + "testing" + + "github.com/mhersson/vectorsigma/examples/error-events/orderprocessor" +) + +func TestOrderProcessor_Run(t *testing.T) { + type fields struct { + Context *orderprocessor.Context + CurrentState orderprocessor.StateName + ExtendedState *orderprocessor.ExtendedState + StateConfigs map[orderprocessor.StateName]orderprocessor.StateConfig + } + tests := []struct { + name string + fields fields + }{ + {name: "Run the machine"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fsm := orderprocessor.New() + fsm.Run() + }) + } +} diff --git a/examples/error-events/statemachine.md b/examples/error-events/statemachine.md new file mode 100644 index 0000000..58e5ea6 --- /dev/null +++ b/examples/error-events/statemachine.md @@ -0,0 +1,75 @@ +# Order Processing State Machine with Error Events + +This example demonstrates error event handling in VectorSigma. + +```plantuml +@startuml + +title OrderProcessor + +[*] --> Initializing + +state Initializing { + [*] --> InitializingContext + InitializingContext: do / InitializeContext + InitializingContext --> LoadingSubject + LoadingSubject: do / LoadSubject + LoadingSubject --> [*] +} + +Initializing --> ErrorHandling : error +Initializing --> Processing + +state Processing { + [*] --> ValidatingOrder + ValidatingOrder: do / ValidateOrder + ValidatingOrder --> ChargingPayment + ChargingPayment: do / ChargePayment + ChargingPayment --> FulfillingOrder + FulfillingOrder: do / FulfillOrder + FulfillingOrder --> [*] +} + +Processing --> ErrorHandling : error +Processing --> Completed + +ErrorHandling: do / HandleError +ErrorHandling --> Completed + +Completed --> [*] + +@enduml +``` + +## Key Features + +1. **Composite State Error Handling**: Both `Initializing` and `Processing` composite states have error event transitions +2. **Error Propagation**: When any action within a composite state fails, the error event is triggered +3. **Centralized Error Handling**: All errors route to the `ErrorHandling` state +4. **No IsError Guards Needed**: Clean, declarative error handling without verbose guard checks + +## Comparison with Traditional Approach + +### Traditional (IsError Guard): +```plantuml +InitializingContext: do / InitializeContext +InitializingContext --> [*] : IsError +InitializingContext --> LoadingSubject + +LoadingSubject: do / LoadSubject +LoadingSubject --> [*] : IsError +LoadingSubject --> [*] +``` + +### With Error Events: +```plantuml +state Initializing { + InitializingContext: do / InitializeContext + InitializingContext --> LoadingSubject + LoadingSubject: do / LoadSubject + LoadingSubject --> [*] +} +Initializing --> ErrorHandling : error +``` + +The error event approach is cleaner and centralizes error handling at the composite state level. diff --git a/internal/statemachine/zz_generated_statemachine.go b/internal/statemachine/zz_generated_statemachine.go index 4ad8812..fbacc32 100644 --- a/internal/statemachine/zz_generated_statemachine.go +++ b/internal/statemachine/zz_generated_statemachine.go @@ -70,10 +70,11 @@ type Guard struct { // StateConfig holds the actions and guards for a state. type StateConfig struct { - Actions []Action - Guards []Guard - Transitions map[int]StateName // Maps guard index to the next state - Composite CompositeState + Actions []Action + Guards []Guard + Transitions map[int]StateName // Maps guard index to the next state + Composite CompositeState + ErrorTransition StateName // Target state for error events (empty if not defined) } type CompositeState struct { @@ -302,6 +303,13 @@ func run(fsm *VectorSigma, stateConfigs map[StateName]StateConfig, depth int) er if err != nil { fsm.Context.Logger.Error("composite state machine failed", "state", fsm.CurrentState, "error", err) fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this composite state + if config.ErrorTransition != "" { + fsm.Context.Logger.Debug("error event triggered", "from", parentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } } fsm.Context.Logger.Debug("exiting composite state", "state", parentState) @@ -312,6 +320,13 @@ func run(fsm *VectorSigma, stateConfigs map[StateName]StateConfig, depth int) er if err != nil { fsm.Context.Logger.Error("action failed", "state", fsm.CurrentState, "error", err) fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this state + if config.ErrorTransition != "" { + fsm.Context.Logger.Debug("error event triggered", "from", fsm.CurrentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } } } diff --git a/pkgs/generator/generator.go b/pkgs/generator/generator.go index a781489..d3fd0be 100644 --- a/pkgs/generator/generator.go +++ b/pkgs/generator/generator.go @@ -63,10 +63,25 @@ type Generator struct { func (g *Generator) ExecuteTemplate(filename string) ([]byte, error) { titleTransformer := cases.Title(language.English) + // transitionIndex calculates the correct index for a transition in the Transitions map + // by counting how many non-event transitions come before it + transitionIndex := func(transitions []uml.Transition, currentIndex int) int { + count := 0 + + for i := 0; i < currentIndex; i++ { + if !transitions[i].IsEvent { + count++ + } + } + + return count + } + funcMap := template.FuncMap{ - "title": titleTransformer.String, - "toLower": strings.ToLower, - "toUpper": strings.ToUpper, + "title": titleTransformer.String, + "toLower": strings.ToLower, + "toUpper": strings.ToUpper, + "transitionIndex": transitionIndex, } tmpl, err := template.New(filepath.Base(filename)).Funcs(funcMap).ParseFS(templates, filename) diff --git a/pkgs/generator/templates/application/statemachine.go.tmpl b/pkgs/generator/templates/application/statemachine.go.tmpl index 0754865..1874fe1 100644 --- a/pkgs/generator/templates/application/statemachine.go.tmpl +++ b/pkgs/generator/templates/application/statemachine.go.tmpl @@ -50,10 +50,11 @@ type Guard struct { // StateConfig holds the actions and guards for a state. type StateConfig struct { - Actions []Action - Guards []Guard - Transitions map[int]StateName // Maps guard index to the next state - Composite CompositeState + Actions []Action + Guards []Guard + Transitions map[int]StateName // Maps guard index to the next state + Composite CompositeState + ErrorTransition StateName // Target state for error events (empty if not defined) } type CompositeState struct { @@ -87,6 +88,7 @@ func New() *{{ .FSM.Title }} { } {{- define "stateConfigStructure" -}} +{{- $allTransitions := .Transitions -}} { Actions: []Action{ {{- range $action := .Actions }} @@ -95,7 +97,7 @@ func New() *{{ .FSM.Title }} { }, Guards: []Guard{ {{- range $trans := .Transitions }} - {{- if ne $trans.Guard "" }} + {{- if and (ne $trans.Guard "") (not $trans.IsEvent) }} {{- if $trans.Action }} { Name: {{ $trans.Guard }}, @@ -115,7 +117,9 @@ func New() *{{ .FSM.Title }} { }, Transitions: map[int]StateName{ {{- range $ind, $trans := .Transitions }} - {{ $ind }}: {{ $trans.Target }}, + {{- if not $trans.IsEvent }} + {{ transitionIndex $allTransitions $ind }}: {{ $trans.Target }}, + {{- end }} {{- end }} }, {{- if ne .Composite.InitialState "" }} @@ -131,6 +135,11 @@ func New() *{{ .FSM.Title }} { }, }, {{- end }} + {{- range $trans := .Transitions }} + {{- if and $trans.IsEvent (eq $trans.Guard "error") }} + ErrorTransition: {{ $trans.Target }}, + {{- end }} + {{- end }} } {{- end -}} @@ -180,6 +189,13 @@ func run(fsm *{{ .FSM.Title }}, stateConfigs map[StateName]StateConfig, depth i if err != nil { fsm.Context.Logger.Error("composite state machine failed", "state", fsm.CurrentState, "error", err) fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this composite state + if config.ErrorTransition != "" { + fsm.Context.Logger.Debug("error event triggered", "from", parentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } } fsm.Context.Logger.Debug("exiting composite state", "state", parentState ) @@ -190,6 +206,13 @@ func run(fsm *{{ .FSM.Title }}, stateConfigs map[StateName]StateConfig, depth i if err != nil { fsm.Context.Logger.Error("action failed", "state", fsm.CurrentState, "error", err) fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this state + if config.ErrorTransition != "" { + fsm.Context.Logger.Debug("error event triggered", "from", fsm.CurrentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } } } diff --git a/pkgs/generator/templates/operator/statemachine.go.tmpl b/pkgs/generator/templates/operator/statemachine.go.tmpl index cdf052a..ebb0b14 100644 --- a/pkgs/generator/templates/operator/statemachine.go.tmpl +++ b/pkgs/generator/templates/operator/statemachine.go.tmpl @@ -51,10 +51,11 @@ type Guard struct { // StateConfig holds the actions and guards for a state. type StateConfig struct { - Actions []Action - Guards []Guard - Transitions map[int]StateName // Maps guard index to the next state - Composite CompositeState + Actions []Action + Guards []Guard + Transitions map[int]StateName // Maps guard index to the next state + Composite CompositeState + ErrorTransition StateName // Target state for error events (empty if not defined) } type CompositeState struct { @@ -81,6 +82,7 @@ func New() *{{ .FSM.Title }} { } {{- define "stateConfigStructure" -}} +{{- $allTransitions := .Transitions -}} { Actions: []Action{ {{- range $action := .Actions }} @@ -89,7 +91,7 @@ func New() *{{ .FSM.Title }} { }, Guards: []Guard{ {{- range $trans := .Transitions }} - {{- if ne $trans.Guard "" }} + {{- if and (ne $trans.Guard "") (not $trans.IsEvent) }} {{- if $trans.Action }} { Name: {{ $trans.Guard }}, @@ -109,7 +111,9 @@ func New() *{{ .FSM.Title }} { }, Transitions: map[int]StateName{ {{- range $ind, $trans := .Transitions }} - {{ $ind }}: {{ $trans.Target }}, + {{- if not $trans.IsEvent }} + {{ transitionIndex $allTransitions $ind }}: {{ $trans.Target }}, + {{- end }} {{- end }} }, {{- if ne .Composite.InitialState "" }} @@ -125,6 +129,11 @@ func New() *{{ .FSM.Title }} { }, }, {{- end }} + {{- range $trans := .Transitions }} + {{- if and $trans.IsEvent (eq $trans.Guard "error") }} + ErrorTransition: {{ $trans.Target }}, + {{- end }} + {{- end }} } {{- end -}} @@ -175,6 +184,13 @@ func run(fsm *{{ .FSM.Title }}, stateConfigs map[StateName]StateConfig, depth i if err != nil { fsm.Context.Logger.Error(err, "composite state machine failed", "state", fsm.CurrentState) fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this composite state + if config.ErrorTransition != "" { + fsm.Context.Logger.V(1).Info("error event triggered", "from", parentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } } fsm.Context.Logger.V(1).Info("exiting composite state", "state", parentState) @@ -185,6 +201,13 @@ func run(fsm *{{ .FSM.Title }}, stateConfigs map[StateName]StateConfig, depth i if err != nil { fsm.Context.Logger.Error(err, "action failed", "state", fsm.CurrentState) fsm.ExtendedState.Error = err + + // Check if there's an error event transition defined for this state + if config.ErrorTransition != "" { + fsm.Context.Logger.V(1).Info("error event triggered", "from", fsm.CurrentState, "to", config.ErrorTransition) + fsm.CurrentState = config.ErrorTransition + continue + } } } diff --git a/pkgs/uml/uml.go b/pkgs/uml/uml.go index 33c2cbb..09c7f3c 100644 --- a/pkgs/uml/uml.go +++ b/pkgs/uml/uml.go @@ -30,6 +30,7 @@ import ( const ( InitialState = "InitialState" FinalState = "FinalState" + ErrorEvent = "error" // Special event keyword for error transitions // [*] --> InitialState. Before we replace the [*] with InitialState. firstInitialStatePattern = `^\s*\[\*\].*$` titlePattern = `^title\s(.*)$` @@ -64,6 +65,7 @@ type Transition struct { Guard string GuardParams string Action *Action + IsEvent bool // True if this is an event transition (e.g., "error") } type Action struct { @@ -176,6 +178,9 @@ func (f *FSM) IsGuardedTransition(line string) bool { transition := m[2] guard := m[3] + // Check if this is an error event transition + isEvent := strings.ToLower(guard) == ErrorEvent + // Check if the guard has parameters (m[4] would be the full match including parens, m[5] is the content) guardParams := "" @@ -190,7 +195,10 @@ func (f *FSM) IsGuardedTransition(line string) bool { guardParams = `"` + strings.Join(paramList, `","`) + `"` } - f.Guard(guard) + // Only register guards that are not events + if !isEvent { + f.Guard(guard) + } var action *Action // Check if there is an action behind the guard (after ::) @@ -221,11 +229,12 @@ func (f *FSM) IsGuardedTransition(line string) bool { Guard: guard, GuardParams: guardParams, Action: action, + IsEvent: isEvent, }, }, } } else { - newTransition := Transition{Target: transition, Guard: guard, GuardParams: guardParams, Action: action} + newTransition := Transition{Target: transition, Guard: guard, GuardParams: guardParams, Action: action, IsEvent: isEvent} f.States[state].Transitions = append(f.States[state].Transitions, newTransition) } diff --git a/pkgs/uml/uml_test.go b/pkgs/uml/uml_test.go index 5187f54..e6cc50f 100644 --- a/pkgs/uml/uml_test.go +++ b/pkgs/uml/uml_test.go @@ -960,3 +960,145 @@ Waiting --> [*] }) } } + +func TestParseErrorEvent(t *testing.T) { + type args struct { + data string + } + + tests := []struct { + name string + args args + want *uml.FSM + }{ + { + name: "Error event transition", + want: ¨.FSM{ + InitialState: uml.InitialState, + States: map[string]*uml.State{ + uml.InitialState: { + Name: uml.InitialState, + Transitions: []uml.Transition{ + {Target: "Initializing", Guard: ""}, + }, + }, + "Initializing": { + Name: "Initializing", + Transitions: []uml.Transition{ + { + Target: "ErrorState", + Guard: "error", + IsEvent: true, + }, + {Target: "Processing", Guard: ""}, + }, + Composite: uml.Composite{ + InitialState: uml.InitialState, + States: map[string]*uml.State{ + uml.InitialState: { + Name: uml.InitialState, + Transitions: []uml.Transition{ + {Target: "LoadingContext", Guard: ""}, + }, + }, + "LoadingContext": { + Name: "LoadingContext", + Actions: []uml.Action{{ + Name: "LoadContext", + Params: "", + }}, + Transitions: []uml.Transition{ + {Target: "LoadingData", Guard: ""}, + }, + }, + "LoadingData": { + Name: "LoadingData", + Actions: []uml.Action{{ + Name: "LoadData", + Params: "", + }}, + Transitions: []uml.Transition{ + {Target: uml.FinalState, Guard: ""}, + }, + }, + uml.FinalState: { + Name: uml.FinalState, + }, + }, + }, + }, + "Processing": { + Name: "Processing", + Actions: []uml.Action{{ + Name: "Process", + Params: "", + }}, + Transitions: []uml.Transition{ + {Target: uml.FinalState, Guard: ""}, + }, + }, + "ErrorState": { + Name: "ErrorState", + Actions: []uml.Action{{ + Name: "HandleError", + Params: "", + }}, + Transitions: []uml.Transition{ + {Target: uml.FinalState, Guard: ""}, + }, + }, + uml.FinalState: { + Name: uml.FinalState, + }, + }, + Title: "ErrorEventTest", + ActionNames: []string{"HandleError", "LoadContext", "LoadData", "Process"}, + GuardNames: nil, + AllStates: []string{ + "ErrorState", "FinalState", "InitialState", "Initializing", "LoadingContext", "LoadingData", "Processing", + }, + }, + args: args{ + data: ` +@startuml + +title Error Event Test + +[*] --> Initializing + +state Initializing { + [*] --> LoadingContext + LoadingContext: do / LoadContext + LoadingContext --> LoadingData + LoadingData: do / LoadData + LoadingData --> [*] +} + +Initializing --> ErrorState : error +Initializing --> Processing + +Processing: do / Process +Processing --> [*] + +ErrorState: do / HandleError +ErrorState --> [*] + +@enduml +`, + }, + }, + } + + t.Parallel() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := uml.Parse(tt.args.data); !cmp.Equal(got, tt.want) { + fmt.Println(cmp.Diff(got, tt.want)) + t.Errorf("Parse() = %v, want %v", got, tt.want) + } + }) + } +}