state_machines 0.31.0 → 0.40.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcd04394640ccea1429d2e121a46e2c705f8c16b2987b142858ad2f151c9c287
4
- data.tar.gz: 58cfb25ff972b1b2802730d9084d3c3b27c59a82e694646ce676e38fc661cbb9
3
+ metadata.gz: bc7dae3a88c68f1327da47debac2546fde7c4459346dbfefaabbc4bed5dc5554
4
+ data.tar.gz: 2fbfc7157580635bd546b9096138677d44b58a92578d6341667a1722c91ccb66
5
5
  SHA512:
6
- metadata.gz: c9328eea4f9d8d9e57124571de54bb8f5160a044ef913d3e515bcaac933b7355ccf987c10699c71e94145f44bc1e2387342f350dcbff0100d76a5c1c06645321
7
- data.tar.gz: 07c7bfad14f7ce103eec5d6dfa48a9117bc0a73cb5433447c4bf7b6cdea1b87b894a12ad6b85b5a0cb05d63dd68e291f980eeca40e60754c13d5b5c44a7fcf29
6
+ metadata.gz: 4b5ba6ecdeb4dd612c6865657e0a0b590d3fa67fed522359c7b388ab88401283d953d857fc73fab2fbeb97388c5bb71b5f4e125d70366a03547aea91b991ab35
7
+ data.tar.gz: 07c96c5556ae74d933de3cf1b132cfda9144404361cca87c24c14694e2158e5f0437d65a4ab08b857dca8ad0c453d7fdc674abeb770c528d7ec45e4b060c8ddd
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ module AsyncMode
5
+ # Extensions to Event class for async bang methods
6
+ module AsyncEventExtensions
7
+ # Generate async bang methods for events when async mode is enabled
8
+ def define_helper(scope, method, *args, &block)
9
+ result = super
10
+
11
+ # If this is an async-enabled machine and we're defining an event method
12
+ if scope == :instance && method !~ /_async[!]?$/ && machine.async_mode_enabled?
13
+ qualified_name = method.to_s
14
+
15
+ # Create async version that returns a task
16
+ machine.define_helper(scope, "#{qualified_name}_async") do |machine, object, *method_args, **kwargs|
17
+ # Find the machine that has this event
18
+ target_machine = object.class.state_machines.values.find { |m| m.events[name] }
19
+
20
+ unless defined?(::Async::Task) && ::Async::Task.current?
21
+ raise RuntimeError, "#{qualified_name}_async must be called within an Async context"
22
+ end
23
+
24
+ Async do
25
+ target_machine.events[name].fire(object, *method_args, **kwargs)
26
+ end
27
+ end
28
+
29
+ # Create async bang version that raises exceptions when awaited
30
+ machine.define_helper(scope, "#{qualified_name}_async!") do |machine, object, *method_args, **kwargs|
31
+ # Find the machine that has this event
32
+ target_machine = object.class.state_machines.values.find { |m| m.events[name] }
33
+
34
+ unless defined?(::Async::Task) && ::Async::Task.current?
35
+ raise RuntimeError, "#{qualified_name}_async! must be called within an Async context"
36
+ end
37
+
38
+ Async do
39
+ # Use fire method which will raise exceptions on invalid transitions
40
+ target_machine.events[name].fire(object, *method_args, **kwargs) || raise(StateMachines::InvalidTransition.new(object, target_machine, name))
41
+ end
42
+ end
43
+ end
44
+
45
+ result
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ module AsyncMode
5
+ # Async-aware event firing capabilities using the async gem
6
+ module AsyncEvents
7
+ # Fires an event asynchronously using Async
8
+ # Returns an Async::Task that can be awaited for the result
9
+ #
10
+ # Example:
11
+ # Async do
12
+ # task = vehicle.async_fire_event(:ignite)
13
+ # result = task.wait # => true/false
14
+ # end
15
+ def async_fire_event(event_name, *args)
16
+ # Find the machine that has this event
17
+ machine = self.class.state_machines.values.find { |m| m.events[event_name] }
18
+
19
+ unless machine
20
+ raise ArgumentError, "Event #{event_name} not found in any state machine"
21
+ end
22
+
23
+ # Must be called within an Async context
24
+ unless defined?(::Async::Task) && ::Async::Task.current?
25
+ raise RuntimeError, "async_fire_event must be called within an Async context. Use: Async { vehicle.async_fire_event(:event) }"
26
+ end
27
+
28
+ Async do
29
+ machine.events[event_name].fire(self, *args)
30
+ end
31
+ end
32
+
33
+ # Fires multiple events asynchronously across different state machines
34
+ # Returns an array of Async::Tasks for concurrent execution
35
+ #
36
+ # Example:
37
+ # Async do
38
+ # tasks = vehicle.async_fire_events(:ignite, :buy_insurance)
39
+ # results = tasks.map(&:wait) # => [true, true]
40
+ # end
41
+ def async_fire_events(*event_names)
42
+ event_names.map { |event_name| async_fire_event(event_name) }
43
+ end
44
+
45
+ # Fires an event asynchronously and waits for completion
46
+ # This is a convenience method that creates and waits for the task
47
+ #
48
+ # Example:
49
+ # result = vehicle.fire_event_async(:ignite) # => true/false
50
+ def fire_event_async(event_name, *args)
51
+ raise NoMethodError, "undefined method `fire_event_async' for #{self}" unless has_async_machines?
52
+ # Find the machine that has this event
53
+ machine = self.class.state_machines.values.find { |m| m.events[event_name] }
54
+
55
+ unless machine
56
+ raise ArgumentError, "Event #{event_name} not found in any state machine"
57
+ end
58
+
59
+ if defined?(::Async::Task) && ::Async::Task.current?
60
+ # Already in async context, just fire directly
61
+ machine.events[event_name].fire(self, *args)
62
+ else
63
+ # Create async context and wait for result
64
+ Async do
65
+ machine.events[event_name].fire(self, *args)
66
+ end.wait
67
+ end
68
+ end
69
+
70
+ # Fires multiple events asynchronously and waits for all completions
71
+ # Returns results in the same order as the input events
72
+ #
73
+ # Example:
74
+ # results = vehicle.fire_events_async(:ignite, :buy_insurance) # => [true, true]
75
+ def fire_events_async(*event_names)
76
+ raise NoMethodError, "undefined method `fire_events_async' for #{self}" unless has_async_machines?
77
+ if defined?(::Async::Task) && ::Async::Task.current?
78
+ # Already in async context, run concurrently
79
+ tasks = event_names.map { |event_name| async_fire_event(event_name) }
80
+ tasks.map(&:wait)
81
+ else
82
+ # Create async context and run concurrently
83
+ Async do
84
+ tasks = event_names.map { |event_name| async_fire_event(event_name) }
85
+ tasks.map(&:wait)
86
+ end.wait
87
+ end
88
+ end
89
+
90
+ # Fires an event asynchronously using Async and raises exception on failure
91
+ # Returns an Async::Task that raises StateMachines::InvalidTransition when awaited
92
+ #
93
+ # Example:
94
+ # Async do
95
+ # begin
96
+ # task = vehicle.async_fire_event!(:ignite)
97
+ # result = task.wait
98
+ # puts "Event fired successfully!"
99
+ # rescue StateMachines::InvalidTransition => e
100
+ # puts "Transition failed: #{e.message}"
101
+ # end
102
+ # end
103
+ def async_fire_event!(event_name, *args)
104
+ # Find the machine that has this event
105
+ machine = self.class.state_machines.values.find { |m| m.events[event_name] }
106
+
107
+ unless machine
108
+ raise ArgumentError, "Event #{event_name} not found in any state machine"
109
+ end
110
+
111
+ # Must be called within an Async context
112
+ unless defined?(::Async::Task) && ::Async::Task.current?
113
+ raise RuntimeError, "async_fire_event! must be called within an Async context. Use: Async { vehicle.async_fire_event!(:event) }"
114
+ end
115
+
116
+ Async do
117
+ # Use the bang version which raises exceptions on failure
118
+ machine.events[event_name].fire(self, *args) || raise(StateMachines::InvalidTransition.new(self, machine, event_name))
119
+ end
120
+ end
121
+
122
+ # Fires an event asynchronously and waits for result, raising exceptions on failure
123
+ # This is a convenience method that creates and waits for the task
124
+ #
125
+ # Example:
126
+ # begin
127
+ # result = vehicle.fire_event_async!(:ignite)
128
+ # puts "Event fired successfully!"
129
+ # rescue StateMachines::InvalidTransition => e
130
+ # puts "Transition failed: #{e.message}"
131
+ # end
132
+ def fire_event_async!(event_name, *args)
133
+ raise NoMethodError, "undefined method `fire_event_async!' for #{self}" unless has_async_machines?
134
+ # Find the machine that has this event
135
+ machine = self.class.state_machines.values.find { |m| m.events[event_name] }
136
+
137
+ unless machine
138
+ raise ArgumentError, "Event #{event_name} not found in any state machine"
139
+ end
140
+
141
+ if defined?(::Async::Task) && ::Async::Task.current?
142
+ # Already in async context, just fire directly with bang behavior
143
+ machine.events[event_name].fire(self, *args) || raise(StateMachines::InvalidTransition.new(self, machine, event_name))
144
+ else
145
+ # Create async context and wait for result (may raise exception)
146
+ Async do
147
+ machine.events[event_name].fire(self, *args) || raise(StateMachines::InvalidTransition.new(self, machine, event_name))
148
+ end.wait
149
+ end
150
+ end
151
+
152
+ # Dynamically handle individual event async methods
153
+ # This provides launch_async, launch_async!, arm_weapons_async, etc.
154
+ def method_missing(method_name, *args, **kwargs, &block)
155
+ method_str = method_name.to_s
156
+
157
+ # Check if this is an async event method
158
+ if method_str.end_with?('_async!')
159
+ # Remove the _async! suffix to get the base event method
160
+ base_method = method_str.chomp('_async!').to_sym
161
+
162
+ # Check if the base method exists and this machine is async-enabled
163
+ if respond_to?(base_method) && async_method_for_event?(base_method)
164
+ return handle_individual_event_async_bang(base_method, *args, **kwargs)
165
+ end
166
+ elsif method_str.end_with?('_async')
167
+ # Remove the _async suffix to get the base event method
168
+ base_method = method_str.chomp('_async').to_sym
169
+
170
+ # Check if the base method exists and this machine is async-enabled
171
+ if respond_to?(base_method) && async_method_for_event?(base_method)
172
+ return handle_individual_event_async(base_method, *args, **kwargs)
173
+ end
174
+ end
175
+
176
+ # If not an async method, call the original method_missing
177
+ super
178
+ end
179
+
180
+ # Check if we should respond to async methods for this event
181
+ def respond_to_missing?(method_name, include_private = false)
182
+ # Only provide async methods if this object has async-enabled machines
183
+ return super unless has_async_machines?
184
+
185
+ method_str = method_name.to_s
186
+
187
+ if method_str.end_with?('_async!') || method_str.end_with?('_async')
188
+ base_method = method_str.chomp('_async!').chomp('_async').to_sym
189
+ return respond_to?(base_method) && async_method_for_event?(base_method)
190
+ end
191
+
192
+ super
193
+ end
194
+
195
+ # Check if this object has any async-enabled state machines
196
+ def has_async_machines?
197
+ self.class.state_machines.any? { |name, machine| machine.async_mode_enabled? }
198
+ end
199
+
200
+ private
201
+
202
+ # Check if this event method should have async versions
203
+ def async_method_for_event?(event_method)
204
+ # Find which machine contains this event
205
+ self.class.state_machines.each do |name, machine|
206
+ if machine.async_mode_enabled?
207
+ # Check if this event method belongs to this machine
208
+ machine.events.each do |event|
209
+ qualified_name = event.qualified_name
210
+ if qualified_name.to_sym == event_method || "#{qualified_name}!".to_sym == event_method
211
+ return true
212
+ end
213
+ end
214
+ end
215
+ end
216
+ false
217
+ end
218
+
219
+ # Handle individual event async methods (returns task)
220
+ def handle_individual_event_async(event_method, *args, **kwargs)
221
+
222
+ unless defined?(::Async::Task) && ::Async::Task.current?
223
+ raise RuntimeError, "#{event_method}_async must be called within an Async context"
224
+ end
225
+
226
+ Async do
227
+ send(event_method, *args, **kwargs)
228
+ end
229
+ end
230
+
231
+ # Handle individual event async bang methods (returns task, raises on failure)
232
+ def handle_individual_event_async_bang(event_method, *args, **kwargs)
233
+ # Extract event name from method and use bang version
234
+ bang_method = "#{event_method}!".to_sym
235
+
236
+ unless defined?(::Async::Task) && ::Async::Task.current?
237
+ raise RuntimeError, "#{event_method}_async! must be called within an Async context"
238
+ end
239
+
240
+ Async do
241
+ send(bang_method, *args, **kwargs)
242
+ end
243
+ end
244
+
245
+ # Extract event name from method name, handling namespaced events
246
+ def extract_event_name(method_name)
247
+ method_str = method_name.to_s
248
+
249
+ # Find the machine and event for this method
250
+ self.class.state_machines.each do |name, machine|
251
+ machine.events.each do |event|
252
+ qualified_name = event.qualified_name
253
+ if qualified_name.to_s == method_str || "#{qualified_name}!".to_s == method_str
254
+ return event.name
255
+ end
256
+ end
257
+ end
258
+
259
+ # Fallback: assume the method name is the event name
260
+ method_str.chomp('!').to_sym
261
+ end
262
+
263
+ public
264
+
265
+ # Fires multiple events concurrently within an async context
266
+ # This method should be called from within an Async block
267
+ #
268
+ # Example:
269
+ # Async do
270
+ # results = vehicle.fire_events_concurrent(:ignite, :buy_insurance)
271
+ # end
272
+ def fire_events_concurrent(*event_names)
273
+ unless defined?(::Async::Task) && ::Async::Task.current?
274
+ raise RuntimeError, "fire_events_concurrent must be called within an Async context"
275
+ end
276
+
277
+ tasks = async_fire_events(*event_names)
278
+ tasks.map(&:wait)
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StateMachines
4
+ module AsyncMode
5
+ # Enhanced machine class with async capabilities
6
+ module AsyncMachine
7
+ # Thread-safe state reading for machines
8
+ def read_safely(object, attribute, ivar = false)
9
+ if object.respond_to?(:read_state_safely)
10
+ object.read_state_safely(self, attribute, ivar)
11
+ else
12
+ read(object, attribute, ivar)
13
+ end
14
+ end
15
+
16
+ # Thread-safe state writing for machines
17
+ def write_safely(object, attribute, value, ivar = false)
18
+ if object.respond_to?(:write_state_safely)
19
+ object.write_state_safely(self, attribute, value, ivar)
20
+ else
21
+ write(object, attribute, value, ivar)
22
+ end
23
+ end
24
+
25
+ # Fires an event asynchronously on the given object
26
+ # Returns an Async::Task for concurrent execution
27
+ def async_fire_event(object, event_name, *args)
28
+ unless defined?(::Async::Task) && ::Async::Task.current?
29
+ raise RuntimeError, "async_fire_event must be called within an Async context"
30
+ end
31
+
32
+ Async do
33
+ events[event_name].fire(object, *args)
34
+ end
35
+ end
36
+
37
+ # Creates an async-aware transition collection
38
+ # Supports concurrent transition execution with proper synchronization
39
+ def create_async_transition_collection(transitions, options = {})
40
+ if defined?(AsyncTransitionCollection)
41
+ AsyncTransitionCollection.new(transitions, options)
42
+ else
43
+ # Fallback to regular collection if async collection isn't available
44
+ TransitionCollection.new(transitions, options)
45
+ end
46
+ end
47
+
48
+ # Thread-safe callback execution for async operations
49
+ def run_callbacks_safely(type, object, context, transition)
50
+ if object.respond_to?(:state_machine_mutex)
51
+ object.state_machine_mutex.with_read_lock do
52
+ callbacks[type].each { |callback| callback.call(object, context, transition) }
53
+ end
54
+ else
55
+ callbacks[type].each { |callback| callback.call(object, context, transition) }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -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
@@ -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
@@ -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
@@ -460,6 +460,334 @@ module StateMachines
460
460
  _assert_transition_callback(:after, machine_or_class, options, message)
461
461
  end
462
462
 
463
+ # === Sync Mode Assertions ===
464
+
465
+ # Assert that a state machine is operating in synchronous mode
466
+ #
467
+ # @param object [Object] The object with state machines
468
+ # @param machine_name [Symbol] The name of the state machine (defaults to :state)
469
+ # @param message [String, nil] Custom failure message
470
+ # @return [void]
471
+ # @raise [AssertionError] If the machine has async mode enabled
472
+ #
473
+ # @example
474
+ # user = User.new
475
+ # assert_sm_sync_mode(user) # Uses default :state machine
476
+ # assert_sm_sync_mode(user, :status) # Uses :status machine
477
+ def assert_sm_sync_mode(object, machine_name = :state, message = nil)
478
+ machine = object.class.state_machines[machine_name]
479
+ raise ArgumentError, "No state machine '#{machine_name}' found" unless machine
480
+
481
+ async_enabled = machine.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
482
+ default_message = "Expected state machine '#{machine_name}' to be in sync mode, but async mode is enabled"
483
+
484
+ if defined?(::Minitest)
485
+ refute async_enabled, message || default_message
486
+ elsif defined?(::RSpec)
487
+ expect(async_enabled).to be_falsy, message || default_message
488
+ elsif async_enabled
489
+ raise default_message
490
+ end
491
+ end
492
+
493
+ # Assert that async methods are not available on a sync-only object
494
+ #
495
+ # @param object [Object] The object with state machines
496
+ # @param message [String, nil] Custom failure message
497
+ # @return [void]
498
+ # @raise [AssertionError] If async methods are available
499
+ #
500
+ # @example
501
+ # sync_only_car = Car.new # Car has no async: true machines
502
+ # assert_sm_no_async_methods(sync_only_car)
503
+ def assert_sm_no_async_methods(object, message = nil)
504
+ async_methods = %i[fire_event_async fire_events_async fire_event_async! async_fire_event]
505
+ available_async_methods = async_methods.select { |method| object.respond_to?(method) }
506
+
507
+ default_message = "Expected no async methods to be available, but found: #{available_async_methods.inspect}"
508
+
509
+ if defined?(::Minitest)
510
+ assert_empty available_async_methods, message || default_message
511
+ elsif defined?(::RSpec)
512
+ expect(available_async_methods).to be_empty, message || default_message
513
+ elsif available_async_methods.any?
514
+ raise default_message
515
+ end
516
+ end
517
+
518
+ # Assert that an object has no async-enabled state machines
519
+ #
520
+ # @param object [Object] The object with state machines
521
+ # @param message [String, nil] Custom failure message
522
+ # @return [void]
523
+ # @raise [AssertionError] If any machine has async mode enabled
524
+ #
525
+ # @example
526
+ # sync_only_vehicle = Vehicle.new # All machines are sync-only
527
+ # assert_sm_all_sync(sync_only_vehicle)
528
+ def assert_sm_all_sync(object, message = nil)
529
+ async_machines = []
530
+
531
+ object.class.state_machines.each do |name, machine|
532
+ if machine.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
533
+ async_machines << name
534
+ end
535
+ end
536
+
537
+ default_message = "Expected all state machines to be sync-only, but these have async enabled: #{async_machines.inspect}"
538
+
539
+ if defined?(::Minitest)
540
+ assert_empty async_machines, message || default_message
541
+ elsif defined?(::RSpec)
542
+ expect(async_machines).to be_empty, message || default_message
543
+ elsif async_machines.any?
544
+ raise default_message
545
+ end
546
+ end
547
+
548
+ # Assert that synchronous event execution works correctly
549
+ #
550
+ # @param object [Object] The object with state machines
551
+ # @param event [Symbol] The event to trigger
552
+ # @param expected_state [Symbol] The expected state after transition
553
+ # @param machine_name [Symbol] The name of the state machine (defaults to :state)
554
+ # @param message [String, nil] Custom failure message
555
+ # @return [void]
556
+ # @raise [AssertionError] If sync execution fails
557
+ #
558
+ # @example
559
+ # car = Car.new
560
+ # assert_sm_sync_execution(car, :start, :running)
561
+ # assert_sm_sync_execution(car, :turn_on, :active, :alarm)
562
+ def assert_sm_sync_execution(object, event, expected_state, machine_name = :state, message = nil)
563
+ # Store initial state
564
+ initial_state = object.send(machine_name)
565
+
566
+ # Execute event synchronously
567
+ result = object.send("#{event}!")
568
+
569
+ # Verify immediate state change (no async delay)
570
+ final_state = object.send(machine_name)
571
+
572
+ # Check that transition succeeded
573
+ state_changed = initial_state != final_state
574
+ correct_final_state = final_state.to_s == expected_state.to_s
575
+
576
+ default_message = "Expected sync execution of '#{event}' to change #{machine_name} from '#{initial_state}' to '#{expected_state}', but got '#{final_state}'"
577
+
578
+ if defined?(::Minitest)
579
+ assert result, "Event #{event} should return true on success"
580
+ assert state_changed, "State should change from #{initial_state}"
581
+ assert correct_final_state, message || default_message
582
+ elsif defined?(::RSpec)
583
+ expect(result).to be_truthy, "Event #{event} should return true on success"
584
+ expect(state_changed).to be_truthy, "State should change from #{initial_state}"
585
+ expect(correct_final_state).to be_truthy, message || default_message
586
+ else
587
+ raise "Event #{event} should return true on success" unless result
588
+ raise "State should change from #{initial_state}" unless state_changed
589
+ raise default_message unless correct_final_state
590
+ end
591
+ end
592
+
593
+ # Assert that event execution is immediate (no async delay)
594
+ #
595
+ # @param object [Object] The object with state machines
596
+ # @param event [Symbol] The event to trigger
597
+ # @param machine_name [Symbol] The name of the state machine (defaults to :state)
598
+ # @param message [String, nil] Custom failure message
599
+ # @return [void]
600
+ # @raise [AssertionError] If execution appears to be async
601
+ #
602
+ # @example
603
+ # car = Car.new
604
+ # assert_sm_immediate_execution(car, :start)
605
+ def assert_sm_immediate_execution(object, event, machine_name = :state, message = nil)
606
+ initial_state = object.send(machine_name)
607
+
608
+ # Record start time and execute
609
+ start_time = Time.now
610
+ object.send("#{event}!")
611
+ execution_time = Time.now - start_time
612
+
613
+ final_state = object.send(machine_name)
614
+ state_changed = initial_state != final_state
615
+
616
+ # Should complete very quickly (under 10ms for sync operations)
617
+ is_immediate = execution_time < 0.01
618
+
619
+ default_message = "Expected immediate sync execution of '#{event}', but took #{execution_time}s (likely async)"
620
+
621
+ if defined?(::Minitest)
622
+ assert state_changed, "Event should trigger state change"
623
+ assert is_immediate, message || default_message
624
+ elsif defined?(::RSpec)
625
+ expect(state_changed).to be_truthy, "Event should trigger state change"
626
+ expect(is_immediate).to be_truthy, message || default_message
627
+ else
628
+ raise "Event should trigger state change" unless state_changed
629
+ raise default_message unless is_immediate
630
+ end
631
+ end
632
+
633
+ # === Async Mode Assertions ===
634
+
635
+ # Assert that a state machine is operating in asynchronous mode
636
+ #
637
+ # @param object [Object] The object with state machines
638
+ # @param machine_name [Symbol] The name of the state machine (defaults to :state)
639
+ # @param message [String, nil] Custom failure message
640
+ # @return [void]
641
+ # @raise [AssertionError] If the machine doesn't have async mode enabled
642
+ #
643
+ # @example
644
+ # drone = AutonomousDrone.new
645
+ # assert_sm_async_mode(drone) # Uses default :state machine
646
+ # assert_sm_async_mode(drone, :teleporter_status) # Uses :teleporter_status machine
647
+ def assert_sm_async_mode(object, machine_name = :state, message = nil)
648
+ machine = object.class.state_machines[machine_name]
649
+ raise ArgumentError, "No state machine '#{machine_name}' found" unless machine
650
+
651
+ async_enabled = machine.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
652
+ default_message = "Expected state machine '#{machine_name}' to have async mode enabled, but it's in sync mode"
653
+
654
+ if defined?(::Minitest)
655
+ assert async_enabled, message || default_message
656
+ elsif defined?(::RSpec)
657
+ expect(async_enabled).to be_truthy, message || default_message
658
+ else
659
+ raise default_message unless async_enabled
660
+ end
661
+ end
662
+
663
+ # Assert that async methods are available on an async-enabled object
664
+ #
665
+ # @param object [Object] The object with state machines
666
+ # @param message [String, nil] Custom failure message
667
+ # @return [void]
668
+ # @raise [AssertionError] If async methods are not available
669
+ #
670
+ # @example
671
+ # drone = AutonomousDrone.new # Has async: true machines
672
+ # assert_sm_async_methods(drone)
673
+ def assert_sm_async_methods(object, message = nil)
674
+ async_methods = %i[fire_event_async fire_events_async fire_event_async! async_fire_event]
675
+ available_async_methods = async_methods.select { |method| object.respond_to?(method) }
676
+
677
+ default_message = "Expected async methods to be available, but found none"
678
+
679
+ if defined?(::Minitest)
680
+ refute_empty available_async_methods, message || default_message
681
+ elsif defined?(::RSpec)
682
+ expect(available_async_methods).not_to be_empty, message || default_message
683
+ elsif available_async_methods.empty?
684
+ raise default_message
685
+ end
686
+ end
687
+
688
+ # Assert that an object has async-enabled state machines
689
+ #
690
+ # @param object [Object] The object with state machines
691
+ # @param machine_names [Array<Symbol>] Expected async machine names
692
+ # @param message [String, nil] Custom failure message
693
+ # @return [void]
694
+ # @raise [AssertionError] If expected machines don't have async mode
695
+ #
696
+ # @example
697
+ # drone = AutonomousDrone.new
698
+ # assert_sm_has_async(drone, [:status, :teleporter_status, :shields])
699
+ def assert_sm_has_async(object, machine_names = nil, message = nil)
700
+ if machine_names
701
+ # Check specific machines
702
+ non_async_machines = machine_names.reject do |name|
703
+ machine = object.class.state_machines[name]
704
+ machine&.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
705
+ end
706
+
707
+ default_message = "Expected machines #{machine_names.inspect} to have async enabled, but these don't: #{non_async_machines.inspect}"
708
+
709
+ if defined?(::Minitest)
710
+ assert_empty non_async_machines, message || default_message
711
+ elsif defined?(::RSpec)
712
+ expect(non_async_machines).to be_empty, message || default_message
713
+ elsif non_async_machines.any?
714
+ raise default_message
715
+ end
716
+ else
717
+ # Check that at least one machine has async
718
+ async_machines = object.class.state_machines.select do |name, machine|
719
+ machine.respond_to?(:async_mode_enabled?) && machine.async_mode_enabled?
720
+ end
721
+
722
+ default_message = "Expected at least one state machine to have async enabled, but none found"
723
+
724
+ if defined?(::Minitest)
725
+ refute_empty async_machines, message || default_message
726
+ elsif defined?(::RSpec)
727
+ expect(async_machines).not_to be_empty, message || default_message
728
+ elsif async_machines.empty?
729
+ raise default_message
730
+ end
731
+ end
732
+ end
733
+
734
+ # Assert that individual async event methods are available
735
+ #
736
+ # @param object [Object] The object with state machines
737
+ # @param event [Symbol] The event name
738
+ # @param message [String, nil] Custom failure message
739
+ # @return [void]
740
+ # @raise [AssertionError] If async event methods are not available
741
+ #
742
+ # @example
743
+ # drone = AutonomousDrone.new
744
+ # assert_sm_async_event_methods(drone, :launch) # Checks launch_async and launch_async!
745
+ def assert_sm_async_event_methods(object, event, message = nil)
746
+ async_method = "#{event}_async".to_sym
747
+ async_bang_method = "#{event}_async!".to_sym
748
+
749
+ has_async = object.respond_to?(async_method)
750
+ has_async_bang = object.respond_to?(async_bang_method)
751
+
752
+ default_message = "Expected async event methods #{async_method} and #{async_bang_method} to be available for event :#{event}"
753
+
754
+ if defined?(::Minitest)
755
+ assert has_async, "Missing #{async_method} method"
756
+ assert has_async_bang, "Missing #{async_bang_method} method"
757
+ elsif defined?(::RSpec)
758
+ expect(has_async).to be_truthy, "Missing #{async_method} method"
759
+ expect(has_async_bang).to be_truthy, "Missing #{async_bang_method} method"
760
+ else
761
+ raise "Missing #{async_method} method" unless has_async
762
+ raise "Missing #{async_bang_method} method" unless has_async_bang
763
+ end
764
+ end
765
+
766
+ # Assert that an object has thread-safe state methods when async is enabled
767
+ #
768
+ # @param object [Object] The object with state machines
769
+ # @param message [String, nil] Custom failure message
770
+ # @return [void]
771
+ # @raise [AssertionError] If thread-safe methods are not available
772
+ #
773
+ # @example
774
+ # drone = AutonomousDrone.new
775
+ # assert_sm_thread_safe_methods(drone)
776
+ def assert_sm_thread_safe_methods(object, message = nil)
777
+ thread_safe_methods = %i[state_machine_mutex read_state_safely write_state_safely]
778
+ missing_methods = thread_safe_methods.reject { |method| object.respond_to?(method) }
779
+
780
+ default_message = "Expected thread-safe methods to be available, but missing: #{missing_methods.inspect}"
781
+
782
+ if defined?(::Minitest)
783
+ assert_empty missing_methods, message || default_message
784
+ elsif defined?(::RSpec)
785
+ expect(missing_methods).to be_empty, message || default_message
786
+ elsif missing_methods.any?
787
+ raise default_message
788
+ end
789
+ end
790
+
463
791
  # RSpec-style aliases for event triggering (for consistency with RSpec expectations)
464
792
  alias expect_to_trigger_event assert_sm_triggers_event
465
793
  alias have_triggered_event assert_sm_triggers_event
@@ -160,12 +160,13 @@ module StateMachines
160
160
  # transition.perform(Time.now, false) # => Passes in additional arguments and only sets the state attribute
161
161
  # transition.perform(Time.now, run_action: false) # => Passes in additional arguments and only sets the state attribute
162
162
  def perform(*args)
163
- run_action = true
164
-
165
- if [true, false].include?(args.last)
166
- run_action = args.pop
167
- elsif args.last.is_a?(Hash) && args.last.key?(:run_action)
168
- run_action = args.last.delete(:run_action)
163
+ run_action = case args.last
164
+ in true | false
165
+ args.pop
166
+ in { run_action: }
167
+ args.last.delete(:run_action)
168
+ else
169
+ true
169
170
  end
170
171
 
171
172
  self.args = args
@@ -209,6 +209,11 @@ module StateMachines
209
209
  transition.machine.write(object, :event, nil)
210
210
  transition.machine.write(object, :event_transition, nil)
211
211
  end
212
+
213
+ # Clear stored transitions hash for new cycle (issue #91)
214
+ if !empty? && (obj = first.object)
215
+ obj.instance_variable_set(:@_state_machine_event_transitions, nil)
216
+ end
212
217
 
213
218
  # Rollback only if exceptions occur during before callbacks
214
219
  begin
@@ -222,7 +227,16 @@ module StateMachines
222
227
  # Persists transitions on the object if partial transition was successful.
223
228
  # This allows us to reference them later to complete the transition with
224
229
  # after callbacks.
225
- each { |transition| transition.machine.write(object, :event_transition, transition) } if skip_after && success?
230
+ if skip_after && success?
231
+ each { |transition| transition.machine.write(object, :event_transition, transition) }
232
+
233
+ # Store transitions in a hash by machine name to avoid overwriting (issue #91)
234
+ if !empty?
235
+ transitions_by_machine = object.instance_variable_get(:@_state_machine_event_transitions) || {}
236
+ each { |transition| transitions_by_machine[transition.machine.name] = transition }
237
+ object.instance_variable_set(:@_state_machine_event_transitions, transitions_by_machine)
238
+ end
239
+ end
226
240
  else
227
241
  super
228
242
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StateMachines
4
- VERSION = '0.31.0'
4
+ VERSION = '0.40.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: state_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.0
4
+ version: 0.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -62,6 +62,12 @@ files:
62
62
  - LICENSE.txt
63
63
  - README.md
64
64
  - lib/state_machines.rb
65
+ - lib/state_machines/async_mode.rb
66
+ - lib/state_machines/async_mode/async_event_extensions.rb
67
+ - lib/state_machines/async_mode/async_events.rb
68
+ - lib/state_machines/async_mode/async_machine.rb
69
+ - lib/state_machines/async_mode/async_transition_collection.rb
70
+ - lib/state_machines/async_mode/thread_safe_state.rb
65
71
  - lib/state_machines/branch.rb
66
72
  - lib/state_machines/callback.rb
67
73
  - lib/state_machines/core.rb
@@ -77,6 +83,7 @@ files:
77
83
  - lib/state_machines/integrations/base.rb
78
84
  - lib/state_machines/machine.rb
79
85
  - lib/state_machines/machine/action_hooks.rb
86
+ - lib/state_machines/machine/async_extensions.rb
80
87
  - lib/state_machines/machine/callbacks.rb
81
88
  - lib/state_machines/machine/class_methods.rb
82
89
  - lib/state_machines/machine/configuration.rb