state_machines 0.30.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/README.md +30 -5
- 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/branch.rb +21 -7
- data/lib/state_machines/callback.rb +3 -2
- data/lib/state_machines/eval_helpers.rb +123 -32
- data/lib/state_machines/event.rb +18 -14
- 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 +3 -2
- data/lib/state_machines/state.rb +14 -7
- data/lib/state_machines/test_helper.rb +328 -0
- data/lib/state_machines/transition.rb +15 -5
- data/lib/state_machines/transition_collection.rb +15 -1
- data/lib/state_machines/version.rb +1 -1
- metadata +8 -1
@@ -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
|
@@ -108,16 +108,18 @@ module StateMachines
|
|
108
108
|
# * <tt>:guard</tt> - Whether to guard matches with the if/unless
|
109
109
|
# conditionals defined for this branch. Default is true.
|
110
110
|
#
|
111
|
+
# Event arguments are passed to guard conditions if they accept multiple parameters.
|
112
|
+
#
|
111
113
|
# == Examples
|
112
114
|
#
|
113
115
|
# branch = StateMachines::Branch.new(:parked => :idling, :on => :ignite)
|
114
116
|
#
|
115
117
|
# branch.match(object, :on => :ignite) # => {:to => ..., :from => ..., :on => ...}
|
116
118
|
# branch.match(object, :on => :park) # => nil
|
117
|
-
def match(object, query = {})
|
119
|
+
def match(object, query = {}, event_args = [])
|
118
120
|
StateMachines::OptionsValidator.assert_valid_keys!(query, :from, :to, :on, :guard)
|
119
121
|
|
120
|
-
return unless (match = match_query(query)) && matches_conditions?(object, query)
|
122
|
+
return unless (match = match_query(query)) && matches_conditions?(object, query, event_args)
|
121
123
|
|
122
124
|
match
|
123
125
|
end
|
@@ -178,11 +180,23 @@ module StateMachines
|
|
178
180
|
end
|
179
181
|
|
180
182
|
# Verifies that the conditionals for this branch evaluate to true for the
|
181
|
-
# given object
|
182
|
-
def matches_conditions?(object, query)
|
183
|
-
query[:guard]
|
184
|
-
|
185
|
-
|
183
|
+
# given object. Event arguments are passed to guards that accept multiple parameters.
|
184
|
+
def matches_conditions?(object, query, event_args = [])
|
185
|
+
case [query[:guard], if_condition, unless_condition]
|
186
|
+
in [false, _, _]
|
187
|
+
true
|
188
|
+
in [_, nil, nil]
|
189
|
+
true
|
190
|
+
in [_, if_conds, nil] if if_conds
|
191
|
+
Array(if_conds).all? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
|
192
|
+
in [_, nil, unless_conds] if unless_conds
|
193
|
+
Array(unless_conds).none? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
|
194
|
+
in [_, if_conds, unless_conds] if if_conds || unless_conds
|
195
|
+
Array(if_conds).all? { |condition| evaluate_method_with_event_args(object, condition, event_args) } &&
|
196
|
+
Array(unless_conds).none? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
|
197
|
+
else
|
198
|
+
true
|
199
|
+
end
|
186
200
|
end
|
187
201
|
end
|
188
202
|
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
|
@@ -193,7 +194,7 @@ module StateMachines
|
|
193
194
|
else
|
194
195
|
@methods.each do |method|
|
195
196
|
result = evaluate_method(object, method, *args)
|
196
|
-
throw :halt if @terminator
|
197
|
+
throw :halt if @terminator&.call(result)
|
197
198
|
end
|
198
199
|
end
|
199
200
|
end
|
@@ -47,6 +47,11 @@ module StateMachines
|
|
47
47
|
# the method defines additional arguments other than the object context,
|
48
48
|
# then all arguments are required.
|
49
49
|
#
|
50
|
+
# For guard conditions in state machines, event arguments can be passed
|
51
|
+
# automatically based on the guard's arity:
|
52
|
+
# - Guards with arity 1 receive only the object (backward compatible)
|
53
|
+
# - Guards with arity -1 or > 1 receive object + event arguments
|
54
|
+
#
|
50
55
|
# For example,
|
51
56
|
#
|
52
57
|
# person = Person.new('John Smith')
|
@@ -54,18 +59,30 @@ module StateMachines
|
|
54
59
|
# evaluate_method(person, lambda {|person| person.name}, 21) # => "John Smith"
|
55
60
|
# evaluate_method(person, lambda {|person, age| "#{person.name} is #{age}"}, 21) # => "John Smith is 21"
|
56
61
|
# evaluate_method(person, lambda {|person, age| "#{person.name} is #{age}"}, 21, 'male') # => ArgumentError: wrong number of arguments (3 for 2)
|
62
|
+
#
|
63
|
+
# With event arguments for guards:
|
64
|
+
#
|
65
|
+
# # Single parameter guard (backward compatible)
|
66
|
+
# guard = lambda {|obj| obj.valid? }
|
67
|
+
# evaluate_method_with_event_args(object, guard, [arg1, arg2]) # => calls guard.call(object)
|
68
|
+
#
|
69
|
+
# # Multi-parameter guard (receives event args)
|
70
|
+
# guard = lambda {|obj, *args| obj.valid? && args[0] == :force }
|
71
|
+
# evaluate_method_with_event_args(object, guard, [:force]) # => calls guard.call(object, :force)
|
57
72
|
def evaluate_method(object, method, *args, **, &block)
|
58
73
|
case method
|
59
|
-
|
74
|
+
in Symbol => sym
|
60
75
|
klass = (class << object; self; end)
|
61
|
-
args = [] if (klass.method_defined?(
|
62
|
-
object.send(
|
63
|
-
|
76
|
+
args = [] if (klass.method_defined?(sym) || klass.private_method_defined?(sym)) && object.method(sym).arity.zero?
|
77
|
+
object.send(sym, *args, **, &block)
|
78
|
+
in Proc => proc
|
64
79
|
args.unshift(object)
|
65
|
-
arity =
|
80
|
+
arity = proc.arity
|
66
81
|
# Handle blocks for Procs
|
67
|
-
|
68
|
-
|
82
|
+
case [block_given?, arity]
|
83
|
+
in [true, arity] if arity != 0
|
84
|
+
case arity
|
85
|
+
in 1 | 2
|
69
86
|
# Force the block to be either the only argument or the second one
|
70
87
|
# after the object (may mean additional arguments get discarded)
|
71
88
|
args = args[0, arity - 1] + [block]
|
@@ -73,52 +90,126 @@ module StateMachines
|
|
73
90
|
# insert the block to the end of the args
|
74
91
|
args << block
|
75
92
|
end
|
76
|
-
|
93
|
+
in [_, 0 | 1]
|
77
94
|
# These method types are only called with 0, 1, or n arguments
|
78
95
|
args = args[0, arity]
|
96
|
+
else
|
97
|
+
# No changes needed for other cases
|
79
98
|
end
|
80
99
|
|
81
100
|
# Call the Proc with the arguments
|
82
|
-
|
101
|
+
proc.call(*args, **)
|
83
102
|
|
84
|
-
|
103
|
+
in Method => meth
|
85
104
|
args.unshift(object)
|
86
|
-
arity =
|
105
|
+
arity = meth.arity
|
87
106
|
|
88
107
|
# Methods handle blocks via &block, not as arguments
|
89
108
|
# Only limit arguments if necessary based on arity
|
90
109
|
args = args[0, arity] if [0, 1].include?(arity)
|
91
110
|
|
92
111
|
# Call the Method with the arguments and pass the block
|
93
|
-
|
94
|
-
|
112
|
+
meth.call(*args, **, &block)
|
113
|
+
in String => str
|
95
114
|
# Input validation for string evaluation
|
96
|
-
validate_eval_string(
|
115
|
+
validate_eval_string(str)
|
97
116
|
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
eval(method, object.instance_eval { binding })
|
117
|
+
case [block_given?, StateMachines::Transition.pause_supported?]
|
118
|
+
in [true, true]
|
119
|
+
eval(str, object.instance_eval { binding }, &block)
|
120
|
+
in [true, false]
|
121
|
+
# Support for JRuby and Truffle Ruby, which don't support binding blocks
|
122
|
+
# Need to check with @headius, if jruby 10 does now.
|
123
|
+
eigen = class << object; self; end
|
124
|
+
eigen.class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
125
|
+
def __temp_eval_method__(*args, &b)
|
126
|
+
#{str}
|
127
|
+
end
|
128
|
+
RUBY
|
129
|
+
result = object.__temp_eval_method__(*args, &block)
|
130
|
+
eigen.send(:remove_method, :__temp_eval_method__)
|
131
|
+
result
|
132
|
+
in [false, _]
|
133
|
+
eval(str, object.instance_eval { binding })
|
116
134
|
end
|
117
135
|
else
|
118
136
|
raise ArgumentError, 'Methods must be a symbol denoting the method to call, a block to be invoked, or a string to be evaluated'
|
119
137
|
end
|
120
138
|
end
|
121
139
|
|
140
|
+
# Evaluates a guard method with support for event arguments passed to transitions.
|
141
|
+
# This method uses arity detection to determine whether to pass event arguments
|
142
|
+
# to the guard, ensuring backward compatibility.
|
143
|
+
#
|
144
|
+
# == Parameters
|
145
|
+
# * object - The object context to evaluate within
|
146
|
+
# * method - The guard method/proc to evaluate
|
147
|
+
# * event_args - Array of arguments passed to the event (optional)
|
148
|
+
#
|
149
|
+
# == Arity-based behavior
|
150
|
+
# * Arity 1: Only passes the object (backward compatible)
|
151
|
+
# * Arity -1 or > 1: Passes object + event arguments
|
152
|
+
#
|
153
|
+
# == Examples
|
154
|
+
#
|
155
|
+
# # Backward compatible single-parameter guard
|
156
|
+
# guard = lambda {|obj| obj.valid? }
|
157
|
+
# evaluate_method_with_event_args(object, guard, [:force]) # => calls guard.call(object)
|
158
|
+
#
|
159
|
+
# # New multi-parameter guard receiving event args
|
160
|
+
# guard = lambda {|obj, *args| obj.valid? && args[0] != :skip }
|
161
|
+
# evaluate_method_with_event_args(object, guard, [:skip]) # => calls guard.call(object, :skip)
|
162
|
+
def evaluate_method_with_event_args(object, method, event_args = [])
|
163
|
+
case method
|
164
|
+
in Symbol
|
165
|
+
# Symbol methods currently don't support event arguments
|
166
|
+
# This maintains backward compatibility
|
167
|
+
evaluate_method(object, method)
|
168
|
+
in Proc => proc
|
169
|
+
arity = proc.arity
|
170
|
+
|
171
|
+
# Arity-based decision for backward compatibility using pattern matching
|
172
|
+
case arity
|
173
|
+
in 0
|
174
|
+
proc.call
|
175
|
+
in 1
|
176
|
+
proc.call(object)
|
177
|
+
in -1
|
178
|
+
# Splat parameters: object + all event args
|
179
|
+
proc.call(object, *event_args)
|
180
|
+
in arity if arity > 1
|
181
|
+
# Explicit parameters: object + limited event args
|
182
|
+
args_needed = arity - 1 # Subtract 1 for the object parameter
|
183
|
+
proc.call(object, *event_args[0, args_needed])
|
184
|
+
else
|
185
|
+
# Negative arity other than -1 (unlikely but handle gracefully)
|
186
|
+
proc.call(object, *event_args)
|
187
|
+
end
|
188
|
+
in Method => meth
|
189
|
+
arity = meth.arity
|
190
|
+
|
191
|
+
case arity
|
192
|
+
in 0
|
193
|
+
meth.call
|
194
|
+
in 1
|
195
|
+
meth.call(object)
|
196
|
+
in -1
|
197
|
+
meth.call(object, *event_args)
|
198
|
+
in arity if arity > 1
|
199
|
+
args_needed = arity - 1
|
200
|
+
meth.call(object, *event_args[0, args_needed])
|
201
|
+
else
|
202
|
+
meth.call(object, *event_args)
|
203
|
+
end
|
204
|
+
in String
|
205
|
+
# String evaluation doesn't support event arguments for security
|
206
|
+
evaluate_method(object, method)
|
207
|
+
else
|
208
|
+
# Fall back to standard evaluation
|
209
|
+
evaluate_method(object, method)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
122
213
|
private
|
123
214
|
|
124
215
|
# Validates string input before eval to prevent code injection
|
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
|
@@ -131,12 +133,14 @@ module StateMachines
|
|
131
133
|
# specified, then this will match any to state.
|
132
134
|
# * <tt>:guard</tt> - Whether to guard transitions with the if/unless
|
133
135
|
# conditionals defined for each one. Default is true.
|
134
|
-
|
136
|
+
#
|
137
|
+
# Event arguments are passed to guard conditions if they accept multiple parameters.
|
138
|
+
def transition_for(object, requirements = {}, *event_args)
|
135
139
|
StateMachines::OptionsValidator.assert_valid_keys!(requirements, :from, :to, :guard)
|
136
140
|
requirements[:from] = machine.states.match!(object).name unless (custom_from_state = requirements.include?(:from))
|
137
141
|
|
138
142
|
branches.each do |branch|
|
139
|
-
next unless (match = branch.match(object, requirements))
|
143
|
+
next unless (match = branch.match(object, requirements, event_args))
|
140
144
|
|
141
145
|
# Branch allows for the transition to occur
|
142
146
|
from = requirements[:from]
|
@@ -161,13 +165,13 @@ module StateMachines
|
|
161
165
|
#
|
162
166
|
# Any additional arguments are passed to the StateMachines::Transition#perform
|
163
167
|
# instance method.
|
164
|
-
def fire(object, *)
|
168
|
+
def fire(object, *event_args)
|
165
169
|
machine.reset(object)
|
166
170
|
|
167
|
-
if (transition = transition_for(object))
|
168
|
-
transition.perform(*)
|
171
|
+
if (transition = transition_for(object, {}, *event_args))
|
172
|
+
transition.perform(*event_args)
|
169
173
|
else
|
170
|
-
on_failure(object, *)
|
174
|
+
on_failure(object, *event_args)
|
171
175
|
false
|
172
176
|
end
|
173
177
|
end
|
@@ -204,13 +208,13 @@ module StateMachines
|
|
204
208
|
# event.transition all - :idling => :parked, :idling => same
|
205
209
|
# event # => #<StateMachines::Event name=:park transitions=[all - :idling => :parked, :idling => same]>
|
206
210
|
def inspect
|
207
|
-
transitions = branches.
|
211
|
+
transitions = branches.flat_map do |branch|
|
208
212
|
branch.state_requirements.map do |state_requirement|
|
209
213
|
"#{state_requirement[:from].description} => #{state_requirement[:to].description}"
|
210
|
-
end
|
211
|
-
end
|
214
|
+
end
|
215
|
+
end.join(', ')
|
212
216
|
|
213
|
-
"#<#{self.class} name=#{name.inspect} transitions=[#{transitions
|
217
|
+
"#<#{self.class} name=#{name.inspect} transitions=[#{transitions}]>"
|
214
218
|
end
|
215
219
|
|
216
220
|
protected
|
@@ -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
|
@@ -558,8 +559,8 @@ module StateMachines
|
|
558
559
|
else
|
559
560
|
name = self.name
|
560
561
|
helper_module.class_eval do
|
561
|
-
define_method(method) do |*
|
562
|
-
block.call((scope == :instance ? self.class : self).state_machine(name), self, *
|
562
|
+
define_method(method) do |*args, **kwargs|
|
563
|
+
block.call((scope == :instance ? self.class : self).state_machine(name), self, *args, **kwargs)
|
563
564
|
end
|
564
565
|
end
|
565
566
|
end
|
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
|
@@ -289,9 +290,9 @@ module StateMachines
|
|
289
290
|
predicate_method = "#{qualified_name}?"
|
290
291
|
|
291
292
|
if machine.send(:owner_class_ancestor_has_method?, :instance, predicate_method)
|
292
|
-
|
293
|
+
warn_about_method_conflict(predicate_method, machine.owner_class.ancestors.first)
|
293
294
|
elsif machine.send(:owner_class_has_method?, :instance, predicate_method)
|
294
|
-
|
295
|
+
warn_about_method_conflict(predicate_method, machine.owner_class)
|
295
296
|
else
|
296
297
|
machine.define_helper(:instance, predicate_method) do |machine, object|
|
297
298
|
machine.states.matches?(object, name)
|
@@ -303,5 +304,11 @@ module StateMachines
|
|
303
304
|
def context_name_for(method)
|
304
305
|
:"__#{machine.name}_#{name}_#{method}_#{@context.object_id}__"
|
305
306
|
end
|
307
|
+
|
308
|
+
def warn_about_method_conflict(method, defined_in)
|
309
|
+
return if StateMachines::Machine.ignore_method_conflicts
|
310
|
+
|
311
|
+
warn "Instance method #{method.inspect} is already defined in #{defined_in.inspect}, use generic helper instead or set StateMachines::Machine.ignore_method_conflicts = true."
|
312
|
+
end
|
306
313
|
end
|
307
314
|
end
|