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 +4 -4
- data/lib/state_machines/async_mode/async_event_extensions.rb +49 -0
- data/lib/state_machines/async_mode/async_events.rb +282 -0
- data/lib/state_machines/async_mode/async_machine.rb +60 -0
- data/lib/state_machines/async_mode/async_transition_collection.rb +141 -0
- data/lib/state_machines/async_mode/thread_safe_state.rb +47 -0
- data/lib/state_machines/async_mode.rb +64 -0
- data/lib/state_machines/callback.rb +2 -1
- data/lib/state_machines/event.rb +6 -4
- data/lib/state_machines/event_collection.rb +21 -13
- data/lib/state_machines/machine/async_extensions.rb +88 -0
- data/lib/state_machines/machine/class_methods.rb +4 -0
- data/lib/state_machines/machine/configuration.rb +11 -1
- data/lib/state_machines/machine.rb +1 -0
- data/lib/state_machines/state.rb +6 -5
- data/lib/state_machines/test_helper.rb +328 -0
- data/lib/state_machines/transition.rb +7 -6
- data/lib/state_machines/transition_collection.rb +15 -1
- data/lib/state_machines/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bc7dae3a88c68f1327da47debac2546fde7c4459346dbfefaabbc4bed5dc5554
|
4
|
+
data.tar.gz: 2fbfc7157580635bd546b9096138677d44b58a92578d6341667a1722c91ccb66
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
180
|
+
case type
|
181
|
+
when :around
|
181
182
|
current_method = @methods[index]
|
182
183
|
if current_method
|
183
184
|
yielded = false
|
data/lib/state_machines/event.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
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
|
data/lib/state_machines/state.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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 =
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
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
|
-
|
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
|
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.
|
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
|