state_machines 0.31.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ module AsyncMode
5
+ # Error class for async-specific transition failures
6
+ class AsyncTransitionError < StateMachines::Error
7
+ def initialize(object, machines, failed_events)
8
+ @object = object
9
+ @machines = machines
10
+ @failed_events = failed_events
11
+ super("Failed to perform async transitions: #{failed_events.join(', ')}")
12
+ end
13
+
14
+ attr_reader :object, :machines, :failed_events
15
+ end
16
+
17
+ # Async-aware transition collection that can execute transitions concurrently
18
+ class AsyncTransitionCollection < TransitionCollection
19
+ # Performs transitions asynchronously using Async
20
+ # Provides better concurrency for I/O-bound operations
21
+ def perform_async(&block)
22
+ reset
23
+
24
+ unless defined?(::Async::Task) && ::Async::Task.current?
25
+ return Async do
26
+ perform_async(&block)
27
+ end.wait
28
+ end
29
+
30
+ if valid?
31
+ # Create async tasks for each transition
32
+ tasks = map do |transition|
33
+ Async do
34
+ if use_event_attributes? && !block_given?
35
+ transition.transient = true
36
+ transition.machine.write_safely(object, :event_transition, transition)
37
+ run_actions
38
+ transition
39
+ else
40
+ within_transaction do
41
+ catch(:halt) { run_callbacks(&block) }
42
+ rollback unless success?
43
+ end
44
+ transition
45
+ end
46
+ end
47
+ end
48
+
49
+ # Wait for all tasks to complete
50
+ completed_transitions = []
51
+ tasks.each do |task|
52
+ begin
53
+ result = task.wait
54
+ completed_transitions << result if result
55
+ rescue StandardError => e
56
+ # Handle individual transition failures
57
+ rollback
58
+ raise AsyncTransitionError.new(object, map(&:machine), [e.message])
59
+ end
60
+ end
61
+
62
+ # Check if all transitions succeeded
63
+ @success = completed_transitions.length == length
64
+ end
65
+
66
+ success?
67
+ end
68
+
69
+ # Performs transitions concurrently using threads
70
+ # Better for CPU-bound operations but requires more careful synchronization
71
+ def perform_threaded(&block)
72
+ reset
73
+
74
+ if valid?
75
+ # Use basic thread approach
76
+ threads = []
77
+ results = []
78
+ results_mutex = Concurrent::ReentrantReadWriteLock.new
79
+
80
+ each do |transition|
81
+ threads << Thread.new do
82
+ begin
83
+ result = if use_event_attributes? && !block_given?
84
+ transition.transient = true
85
+ transition.machine.write_safely(object, :event_transition, transition)
86
+ run_actions
87
+ transition
88
+ else
89
+ within_transaction do
90
+ catch(:halt) { run_callbacks(&block) }
91
+ rollback unless success?
92
+ end
93
+ transition
94
+ end
95
+
96
+ results_mutex.with_write_lock { results << result }
97
+ rescue StandardError => e
98
+ # Handle individual transition failures
99
+ rollback
100
+ raise AsyncTransitionError.new(object, [transition.machine], [e.message])
101
+ end
102
+ end
103
+ end
104
+
105
+ # Wait for all threads to complete
106
+ threads.each(&:join)
107
+ @success = results.length == length
108
+ end
109
+
110
+ success?
111
+ end
112
+
113
+ private
114
+
115
+ # Override run_actions to be thread-safe when needed
116
+ def run_actions(&block)
117
+ catch_exceptions do
118
+ @success = if block_given?
119
+ result = yield
120
+ actions.each { |action| results[action] = result }
121
+ !!result
122
+ else
123
+ actions.compact.each do |action|
124
+ next if skip_actions
125
+
126
+ # Use thread-safe write for results
127
+ if object.respond_to?(:state_machine_mutex)
128
+ object.state_machine_mutex.with_write_lock do
129
+ results[action] = object.send(action)
130
+ end
131
+ else
132
+ results[action] = object.send(action)
133
+ end
134
+ end
135
+ results.values.all?
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ module AsyncMode
5
+ # Thread-safe state operations for async-enabled state machines
6
+ # Uses concurrent-ruby for enterprise-grade thread safety
7
+ module ThreadSafeState
8
+ # Gets or creates a reentrant mutex for thread-safe state operations on an object
9
+ # Each object gets its own mutex to avoid global locking
10
+ # Uses Concurrent::ReentrantReadWriteLock for better performance
11
+ def state_machine_mutex
12
+ @_state_machine_mutex ||= Concurrent::ReentrantReadWriteLock.new
13
+ end
14
+
15
+ # Thread-safe version of state reading
16
+ # Ensures atomic read operations across concurrent threads
17
+ def read_state_safely(machine, attribute, ivar = false)
18
+ state_machine_mutex.with_read_lock do
19
+ machine.read(self, attribute, ivar)
20
+ end
21
+ end
22
+
23
+ # Thread-safe version of state writing
24
+ # Ensures atomic write operations across concurrent threads
25
+ def write_state_safely(machine, attribute, value, ivar = false)
26
+ state_machine_mutex.with_write_lock do
27
+ machine.write(self, attribute, value, ivar)
28
+ end
29
+ end
30
+
31
+ # Handle marshalling by excluding the mutex (will be recreated when needed)
32
+ def marshal_dump
33
+ # Get instance variables excluding the mutex
34
+ vars = instance_variables.reject { |var| var == :@_state_machine_mutex }
35
+ vars.map { |var| [var, instance_variable_get(var)] }
36
+ end
37
+
38
+ # Restore marshalled object, mutex will be lazily recreated when needed
39
+ def marshal_load(data)
40
+ data.each do |var, value|
41
+ instance_variable_set(var, value)
42
+ end
43
+ # Don't set @_state_machine_mutex - let it be lazily created
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby Engine Compatibility Check
4
+ # The async gem requires native extensions and Fiber scheduler support
5
+ # which are not available on JRuby or TruffleRuby
6
+ if RUBY_ENGINE == 'jruby' || RUBY_ENGINE == 'truffleruby'
7
+ raise LoadError, <<~ERROR
8
+ StateMachines::AsyncMode is not available on #{RUBY_ENGINE}.
9
+
10
+ The async gem requires native extensions (io-event) and Fiber scheduler support
11
+ which are not implemented in #{RUBY_ENGINE}. AsyncMode is only supported on:
12
+
13
+ • MRI Ruby (CRuby) 3.2+
14
+ • Other Ruby engines with full Fiber scheduler support
15
+
16
+ If you need async support on #{RUBY_ENGINE}, consider using:
17
+ • java.util.concurrent classes (JRuby)
18
+ • Native threading libraries for your platform
19
+ • Or stick with synchronous state machines
20
+ ERROR
21
+ end
22
+
23
+ # Load required gems with version constraints
24
+ gem 'async', '>= 2.25.0'
25
+ gem 'concurrent-ruby', '>= 1.3.5' # Security is not negotiable - enterprise-grade thread safety required
26
+
27
+ require 'async'
28
+ require 'concurrent-ruby'
29
+
30
+ # Load all async mode components
31
+ require_relative 'async_mode/thread_safe_state'
32
+ require_relative 'async_mode/async_events'
33
+ require_relative 'async_mode/async_event_extensions'
34
+ require_relative 'async_mode/async_machine'
35
+ require_relative 'async_mode/async_transition_collection'
36
+
37
+ module StateMachines
38
+ # AsyncMode provides asynchronous state machine capabilities using the async gem
39
+ # This module enables concurrent, thread-safe state operations for high-performance applications
40
+ #
41
+ # @example Basic usage
42
+ # class AutonomousDrone < StarfleetShip
43
+ # state_machine :teleporter_status, async: true do
44
+ # event :power_up do
45
+ # transition offline: :charging
46
+ # end
47
+ # end
48
+ # end
49
+ #
50
+ # drone = AutonomousDrone.new
51
+ # Async do
52
+ # result = drone.fire_event_async(:power_up) # => true
53
+ # task = drone.power_up_async! # => Async::Task
54
+ # end
55
+ #
56
+ module AsyncMode
57
+ # All components are loaded from separate files:
58
+ # - ThreadSafeState: Mutex-based thread safety
59
+ # - AsyncEvents: Async event firing methods
60
+ # - AsyncEventExtensions: Event method generation
61
+ # - AsyncMachine: Machine-level async capabilities
62
+ # - AsyncTransitionCollection: Concurrent transition execution
63
+ end
64
+ end
@@ -34,6 +34,12 @@ module StateMachines
34
34
  # Build conditionals
35
35
  @if_condition = options.delete(:if)
36
36
  @unless_condition = options.delete(:unless)
37
+ @if_state_condition = options.delete(:if_state)
38
+ @unless_state_condition = options.delete(:unless_state)
39
+ @if_all_states_condition = options.delete(:if_all_states)
40
+ @unless_all_states_condition = options.delete(:unless_all_states)
41
+ @if_any_state_condition = options.delete(:if_any_state)
42
+ @unless_any_state_condition = options.delete(:unless_any_state)
37
43
 
38
44
  # Build event requirement
39
45
  @event_requirement = build_matcher(options, :on, :except_on)
@@ -182,21 +188,56 @@ module StateMachines
182
188
  # Verifies that the conditionals for this branch evaluate to true for the
183
189
  # given object. Event arguments are passed to guards that accept multiple parameters.
184
190
  def matches_conditions?(object, query, event_args = [])
185
- case [query[:guard], if_condition, unless_condition]
186
- in [false, _, _]
187
- true
188
- in [_, nil, nil]
189
- true
190
- in [_, if_conds, nil] if if_conds
191
- Array(if_conds).all? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
192
- in [_, nil, unless_conds] if unless_conds
193
- Array(unless_conds).none? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
194
- in [_, if_conds, unless_conds] if if_conds || unless_conds
195
- Array(if_conds).all? { |condition| evaluate_method_with_event_args(object, condition, event_args) } &&
196
- Array(unless_conds).none? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
197
- else
198
- true
191
+ return true if query[:guard] == false
192
+
193
+ # Evaluate original if/unless conditions
194
+ if_passes = !if_condition || Array(if_condition).all? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
195
+ unless_passes = !unless_condition || Array(unless_condition).none? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
196
+
197
+ return false unless if_passes && unless_passes
198
+
199
+ # Consolidate all state guards
200
+ state_guards = {
201
+ if_state: @if_state_condition,
202
+ unless_state: @unless_state_condition,
203
+ if_all_states: @if_all_states_condition,
204
+ unless_all_states: @unless_all_states_condition,
205
+ if_any_state: @if_any_state_condition,
206
+ unless_any_state: @unless_any_state_condition
207
+ }.compact
208
+
209
+ return true if state_guards.empty?
210
+
211
+ validate_and_check_state_guards(object, state_guards)
212
+ end
213
+
214
+ private
215
+
216
+ def validate_and_check_state_guards(object, guards)
217
+ guards.all? do |guard_type, conditions|
218
+ case guard_type
219
+ when :if_state, :if_all_states
220
+ conditions.all? { |machine, state| check_state(object, machine, state) }
221
+ when :unless_state
222
+ conditions.none? { |machine, state| check_state(object, machine, state) }
223
+ when :if_any_state
224
+ conditions.any? { |machine, state| check_state(object, machine, state) }
225
+ when :unless_all_states
226
+ !conditions.all? { |machine, state| check_state(object, machine, state) }
227
+ when :unless_any_state
228
+ conditions.none? { |machine, state| check_state(object, machine, state) }
229
+ end
199
230
  end
200
231
  end
232
+
233
+ def check_state(object, machine_name, state_name)
234
+ machine = object.class.state_machines[machine_name]
235
+ raise ArgumentError, "State machine '#{machine_name}' is not defined for #{object.class.name}" unless machine
236
+
237
+ state = machine.states[state_name]
238
+ raise ArgumentError, "State '#{state_name}' is not defined in state machine '#{machine_name}'" unless state
239
+
240
+ state.matches?(object.send(machine_name))
241
+ end
201
242
  end
202
243
  end
@@ -177,7 +177,8 @@ module StateMachines
177
177
  # order. The callback will only halt if the resulting value from the
178
178
  # method passes the terminator.
179
179
  def run_methods(object, context = {}, index = 0, *args, &block)
180
- if type == :around
180
+ case type
181
+ when :around
181
182
  current_method = @methods[index]
182
183
  if current_method
183
184
  yielded = false
@@ -35,15 +35,17 @@ module StateMachines
35
35
  # * <tt>:human_name</tt> - The human-readable version of this event's name
36
36
  def initialize(machine, name, options = nil, human_name: nil, **extra_options) # :nodoc:
37
37
  # Handle both old hash style and new kwargs style for backward compatibility
38
- if options.is_a?(Hash)
38
+ case options
39
+ in Hash
39
40
  # Old style: initialize(machine, name, {human_name: 'Custom Name'})
40
41
  StateMachines::OptionsValidator.assert_valid_keys!(options, :human_name)
41
42
  human_name = options[:human_name]
42
- else
43
+ in nil
43
44
  # New style: initialize(machine, name, human_name: 'Custom Name')
44
- raise ArgumentError, "Unexpected positional argument: #{options.inspect}" unless options.nil?
45
-
46
45
  StateMachines::OptionsValidator.assert_valid_keys!(extra_options, :human_name) unless extra_options.empty?
46
+ else
47
+ # Handle unexpected options
48
+ raise ArgumentError, "Unexpected positional argument in Event initialize: #{options.inspect}"
47
49
  end
48
50
 
49
51
  @machine = machine
@@ -102,7 +104,7 @@ module StateMachines
102
104
 
103
105
  # Only a certain subset of explicit options are allowed for transition
104
106
  # requirements
105
- StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :except_from, :except_to, :if, :unless) if (options.keys - %i[from to on except_from except_to except_on if unless]).empty?
107
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :except_from, :except_to, :if, :unless, :if_state, :unless_state, :if_all_states, :unless_all_states, :if_any_state, :unless_any_state) if (options.keys - %i[from to on except_from except_to except_on if unless if_state unless_state if_all_states unless_all_states if_any_state unless_any_state]).empty?
106
108
 
107
109
  branches << branch = Branch.new(options.merge(on: name))
108
110
  @known_states |= branch.known_states
@@ -117,19 +117,27 @@ module StateMachines
117
117
  return unless machine.action
118
118
 
119
119
  # TODO, simplify
120
- machine.read(object, :event_transition) || if event_name = machine.read(object, :event)
121
- if event = self[event_name.to_sym, :name]
122
- event.transition_for(object) || begin
123
- # No valid transition: invalidate
124
- machine.invalidate(object, :event, :invalid_event, [[:state, machine.states.match!(object).human_name(object.class)]]) if invalidate
125
- false
126
- end
127
- else
128
- # Event is unknown: invalidate
129
- machine.invalidate(object, :event, :invalid) if invalidate
130
- false
131
- end
132
- end
120
+ # First try the regular event_transition
121
+ transition = machine.read(object, :event_transition)
122
+
123
+ # If not found and we have stored transitions by machine (issue #91)
124
+ if !transition && (transitions_by_machine = object.instance_variable_get(:@_state_machine_event_transitions))
125
+ transition = transitions_by_machine[machine.name]
126
+ end
127
+
128
+ transition || if event_name = machine.read(object, :event)
129
+ if event = self[event_name.to_sym, :name]
130
+ event.transition_for(object) || begin
131
+ # No valid transition: invalidate
132
+ machine.invalidate(object, :event, :invalid_event, [[:state, machine.states.match!(object).human_name(object.class)]]) if invalidate
133
+ false
134
+ end
135
+ else
136
+ # Event is unknown: invalidate
137
+ machine.invalidate(object, :event, :invalid) if invalidate
138
+ false
139
+ end
140
+ end
133
141
  end
134
142
 
135
143
  private
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file provides optional async extensions for the Machine class.
4
+ # It should only be loaded when async functionality is explicitly requested.
5
+
6
+ module StateMachines
7
+ class Machine
8
+ # AsyncMode extensions for the Machine class
9
+ # Provides async-aware methods while maintaining backward compatibility
10
+ module AsyncExtensions
11
+ # Instance methods added to Machine for async support
12
+
13
+ # Configure this specific machine instance for async mode
14
+ #
15
+ # Example:
16
+ # class Vehicle
17
+ # state_machine initial: :parked do
18
+ # configure_async_mode! # Enable async for this machine
19
+ #
20
+ # event :ignite do
21
+ # transition parked: :idling
22
+ # end
23
+ # end
24
+ # end
25
+ def configure_async_mode!(enabled = true)
26
+ if enabled
27
+ begin
28
+ require 'state_machines/async_mode'
29
+ @async_mode_enabled = true
30
+
31
+ owner_class.include(StateMachines::AsyncMode::ThreadSafeState)
32
+ owner_class.include(StateMachines::AsyncMode::AsyncEvents)
33
+ self.extend(StateMachines::AsyncMode::AsyncMachine)
34
+
35
+ # Extend events to generate async versions
36
+ events.each do |event|
37
+ event.extend(StateMachines::AsyncMode::AsyncEventExtensions)
38
+ end
39
+ rescue LoadError => e
40
+ # Fallback to sync mode with warning (only once per class)
41
+ unless owner_class.instance_variable_get(:@async_fallback_warned)
42
+ warn <<~WARNING
43
+ ⚠️ #{owner_class.name}: Async mode requested but not available on #{RUBY_ENGINE}.
44
+
45
+ #{e.message}
46
+
47
+ ⚠️ Falling back to synchronous mode. Results may be unpredictable due to engine limitations.
48
+ For production async support, use MRI Ruby (CRuby) 3.2+
49
+ WARNING
50
+ owner_class.instance_variable_set(:@async_fallback_warned, true)
51
+ end
52
+
53
+ @async_mode_enabled = false
54
+ end
55
+ else
56
+ @async_mode_enabled = false
57
+ end
58
+
59
+ self
60
+ end
61
+
62
+ # Check if this specific machine instance has async mode enabled
63
+ def async_mode_enabled?
64
+ @async_mode_enabled || false
65
+ end
66
+
67
+ # Thread-safe version of state reading
68
+ def read_safely(object, attribute, ivar = false)
69
+ object.read_state_safely(self, attribute, ivar)
70
+ end
71
+
72
+ # Thread-safe version of state writing
73
+ def write_safely(object, attribute, value, ivar = false)
74
+ object.write_state_safely(self, attribute, value, ivar)
75
+ end
76
+
77
+ # Thread-safe callback execution for async operations
78
+ def run_callbacks_safely(type, object, context, transition)
79
+ object.state_machine_mutex.with_write_lock do
80
+ callbacks[type].each { |callback| callback.call(object, context, transition) }
81
+ end
82
+ end
83
+ end
84
+
85
+ # Include async extensions by default (but only load AsyncMode when requested)
86
+ include AsyncExtensions
87
+ end
88
+ end
@@ -30,6 +30,10 @@ module StateMachines
30
30
  machine = machine.clone
31
31
  machine.initial_state = options[:initial] if options.include?(:initial)
32
32
  machine.owner_class = owner_class
33
+ # Configure async mode if requested in options
34
+ if options.include?(:async)
35
+ machine.configure_async_mode!(options[:async])
36
+ end
33
37
  end
34
38
 
35
39
  # Evaluate DSL
@@ -6,7 +6,7 @@ module StateMachines
6
6
  # Initializes a new state machine with the given configuration.
7
7
  def initialize(owner_class, *args, &)
8
8
  options = args.last.is_a?(Hash) ? args.pop : {}
9
- StateMachines::OptionsValidator.assert_valid_keys!(options, :attribute, :initial, :initialize, :action, :plural, :namespace, :integration, :messages, :use_transactions)
9
+ StateMachines::OptionsValidator.assert_valid_keys!(options, :attribute, :initial, :initialize, :action, :plural, :namespace, :integration, :messages, :use_transactions, :async)
10
10
 
11
11
  # Find an integration that matches this machine's owner class
12
12
  @integration = if options.include?(:integration)
@@ -35,6 +35,8 @@ module StateMachines
35
35
  @use_transactions = options[:use_transactions]
36
36
  @initialize_state = options[:initialize]
37
37
  @action_hook_defined = false
38
+ @async_requested = options[:async]
39
+
38
40
  self.owner_class = owner_class
39
41
 
40
42
  # Merge with sibling machine configurations
@@ -47,6 +49,12 @@ module StateMachines
47
49
 
48
50
  # Evaluate DSL
49
51
  instance_eval(&) if block_given?
52
+
53
+ # Configure async mode if requested, after owner_class is set and DSL is evaluated
54
+ if @async_requested
55
+ configure_async_mode!(true)
56
+ end
57
+
50
58
  self.initial_state = options[:initial] unless sibling_machines.any?
51
59
  end
52
60
 
@@ -61,6 +69,8 @@ module StateMachines
61
69
  @states = @states.dup
62
70
  @states.machine = self
63
71
  @callbacks = { before: @callbacks[:before].dup, after: @callbacks[:after].dup, failure: @callbacks[:failure].dup }
72
+ @async_requested = orig.instance_variable_get(:@async_requested)
73
+ @async_mode_enabled = orig.instance_variable_get(:@async_mode_enabled)
64
74
  end
65
75
 
66
76
  # Sets the class which is the owner of this state machine. Any methods
@@ -14,6 +14,7 @@ require_relative 'machine/event_methods'
14
14
  require_relative 'machine/callbacks'
15
15
  require_relative 'machine/rendering'
16
16
  require_relative 'machine/integration'
17
+ require_relative 'machine/async_extensions'
17
18
  require_relative 'syntax_validator'
18
19
 
19
20
  module StateMachines
@@ -55,7 +55,8 @@ module StateMachines
55
55
  # * <tt>:human_name</tt> - The human-readable version of this state's name
56
56
  def initialize(machine, name, options = nil, initial: false, value: :__not_provided__, cache: nil, if: nil, human_name: nil, **extra_options) # :nodoc:
57
57
  # Handle both old hash style and new kwargs style for backward compatibility
58
- if options.is_a?(Hash)
58
+ case options
59
+ in Hash
59
60
  # Old style: initialize(machine, name, {initial: true, value: 'foo'})
60
61
  StateMachines::OptionsValidator.assert_valid_keys!(options, :initial, :value, :cache, :if, :human_name)
61
62
  initial = options.fetch(:initial, false)
@@ -63,13 +64,13 @@ module StateMachines
63
64
  cache = options[:cache]
64
65
  if_condition = options[:if]
65
66
  human_name = options[:human_name]
66
- else
67
+ in nil
67
68
  # New style: initialize(machine, name, initial: true, value: 'foo')
68
- # options parameter should be nil in this case
69
- raise ArgumentError, "Unexpected positional argument: #{options.inspect}" unless options.nil?
70
-
71
69
  StateMachines::OptionsValidator.assert_valid_keys!(extra_options, :initial, :value, :cache, :if, :human_name) unless extra_options.empty?
72
70
  if_condition = binding.local_variable_get(:if) # 'if' is a keyword, need special handling
71
+ else
72
+ # Handle unexpected options
73
+ raise ArgumentError, "Unexpected positional argument in State initialize: #{options.inspect}"
73
74
  end
74
75
 
75
76
  @machine = machine