state_machines 0.40.0 → 0.100.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 +202 -0
- data/lib/state_machines/async_mode.rb +1 -1
- data/lib/state_machines/branch.rb +58 -14
- data/lib/state_machines/eval_helpers.rb +6 -8
- data/lib/state_machines/event.rb +1 -1
- data/lib/state_machines/event_collection.rb +2 -2
- data/lib/state_machines/machine/async_extensions.rb +1 -1
- data/lib/state_machines/machine/class_methods.rb +1 -3
- data/lib/state_machines/machine/configuration.rb +1 -3
- data/lib/state_machines/machine.rb +0 -2
- data/lib/state_machines/test_helper.rb +0 -1
- data/lib/state_machines/transition.rb +194 -69
- data/lib/state_machines/transition_collection.rb +32 -12
- data/lib/state_machines/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c2e1765c263d1be7747cf812e195b95ab82d183dbf62d83a4014bc47e34132ee
|
4
|
+
data.tar.gz: 35fc3eba5b2a7321436de83091467b0f9e65309356e7d948479acd46be85ee68
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7b76d0c6596909901ccbeedf46b581b0fda2289466ae9c0bd402dc8b40db7d39fe74cbe7792a3b49c439e920ac6dd92c6bedfb51236155a3d0e61b0d7a84db51
|
7
|
+
data.tar.gz: 887b286630bd17fb51c9b00550ee3c6822fd3d1b0c2084630d4cf3e960c66abda12962e050bd09c1829e7091694d120378ad15607ba02f1f2594518b15173976
|
data/README.md
CHANGED
@@ -36,6 +36,8 @@ Below is an example of many of the features offered by this plugin, including:
|
|
36
36
|
* Namespaced states
|
37
37
|
* Transition callbacks
|
38
38
|
* Conditional transitions
|
39
|
+
* Coordinated state management guards
|
40
|
+
* Asynchronous state machines (async: true)
|
39
41
|
* State-driven instance behavior
|
40
42
|
* Customized state values
|
41
43
|
* Parallel events
|
@@ -245,6 +247,206 @@ vehicle.alarm_state_name # => :active
|
|
245
247
|
|
246
248
|
vehicle.fire_events!(:ignite, :enable_alarm) # => StateMachines:InvalidParallelTransition: Cannot run events in parallel: ignite, enable_alarm
|
247
249
|
|
250
|
+
# Coordinated State Management
|
251
|
+
|
252
|
+
State machines can coordinate with each other using state guards, allowing transitions to depend on the state of other state machines within the same object. This enables complex system modeling where components are interdependent.
|
253
|
+
|
254
|
+
## State Guard Options
|
255
|
+
|
256
|
+
### Single State Guards
|
257
|
+
|
258
|
+
* `:if_state` - Transition only if another state machine is in a specific state.
|
259
|
+
* `:unless_state` - Transition only if another state machine is NOT in a specific state.
|
260
|
+
|
261
|
+
```ruby
|
262
|
+
class TorpedoSystem
|
263
|
+
state_machine :bay_doors, initial: :closed do
|
264
|
+
event :open do
|
265
|
+
transition closed: :open
|
266
|
+
end
|
267
|
+
|
268
|
+
event :close do
|
269
|
+
transition open: :closed
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
state_machine :torpedo_status, initial: :loaded do
|
274
|
+
event :fire_torpedo do
|
275
|
+
# Can only fire torpedo if bay doors are open
|
276
|
+
transition loaded: :fired, if_state: { bay_doors: :open }
|
277
|
+
end
|
278
|
+
|
279
|
+
event :reload do
|
280
|
+
# Can only reload if bay doors are closed (for safety)
|
281
|
+
transition fired: :loaded, unless_state: { bay_doors: :open }
|
282
|
+
end
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
system = TorpedoSystem.new
|
287
|
+
system.fire_torpedo # => false (bay doors are closed)
|
288
|
+
|
289
|
+
system.open_bay_doors!
|
290
|
+
system.fire_torpedo # => true (bay doors are now open)
|
291
|
+
```
|
292
|
+
|
293
|
+
### Multiple State Guards
|
294
|
+
|
295
|
+
* `:if_all_states` - Transition only if ALL specified state machines are in their respective states.
|
296
|
+
* `:unless_all_states` - Transition only if NOT ALL specified state machines are in their respective states.
|
297
|
+
* `:if_any_state` - Transition only if ANY of the specified state machines are in their respective states.
|
298
|
+
* `:unless_any_state` - Transition only if NONE of the specified state machines are in their respective states.
|
299
|
+
|
300
|
+
```ruby
|
301
|
+
class StarshipBridge
|
302
|
+
state_machine :shields, initial: :down
|
303
|
+
state_machine :weapons, initial: :offline
|
304
|
+
state_machine :warp_core, initial: :stable
|
305
|
+
|
306
|
+
state_machine :alert_status, initial: :green do
|
307
|
+
event :red_alert do
|
308
|
+
# Red alert if ANY critical system needs attention
|
309
|
+
transition green: :red, if_any_state: { warp_core: :critical, shields: :down }
|
310
|
+
end
|
311
|
+
|
312
|
+
event :battle_stations do
|
313
|
+
# Battle stations only if ALL combat systems are ready
|
314
|
+
transition green: :battle, if_all_states: { shields: :up, weapons: :armed }
|
315
|
+
end
|
316
|
+
end
|
317
|
+
end
|
318
|
+
```
|
319
|
+
|
320
|
+
## Error Handling
|
321
|
+
|
322
|
+
State guards provide comprehensive error checking:
|
323
|
+
|
324
|
+
```ruby
|
325
|
+
# Referencing a non-existent state machine
|
326
|
+
event :invalid, if_state: { nonexistent_machine: :some_state }
|
327
|
+
# => ArgumentError: State machine 'nonexistent_machine' is not defined for StarshipBridge
|
328
|
+
|
329
|
+
# Referencing a non-existent state
|
330
|
+
event :another_invalid, if_state: { shields: :nonexistent_state }
|
331
|
+
# => ArgumentError: State 'nonexistent_state' is not defined in state machine 'shields'
|
332
|
+
```
|
333
|
+
|
334
|
+
# Asynchronous State Machines
|
335
|
+
|
336
|
+
State machines can operate asynchronously for high-performance applications. This is ideal for I/O-bound tasks, such as in web servers or other concurrent environments, where you don't want a long-running state transition (like one involving a network call) to block the entire thread.
|
337
|
+
|
338
|
+
This feature is powered by the [async](https://github.com/socketry/async) gem and uses `concurrent-ruby` for enterprise-grade thread safety.
|
339
|
+
|
340
|
+
## Platform Compatibility
|
341
|
+
|
342
|
+
**Supported Platforms:**
|
343
|
+
* MRI Ruby (CRuby) 3.2+
|
344
|
+
* Other Ruby engines with full Fiber scheduler support
|
345
|
+
|
346
|
+
**Unsupported Platforms:**
|
347
|
+
* JRuby - Falls back to synchronous mode with warnings
|
348
|
+
* TruffleRuby - Falls back to synchronous mode with warnings
|
349
|
+
|
350
|
+
## Basic Async Usage
|
351
|
+
|
352
|
+
Enable async mode by adding `async: true` to your state machine declaration:
|
353
|
+
|
354
|
+
```ruby
|
355
|
+
class AutonomousDrone < StarfleetShip
|
356
|
+
# Async-enabled state machine for autonomous operation
|
357
|
+
state_machine :status, async: true, initial: :docked do
|
358
|
+
event :launch do
|
359
|
+
transition docked: :flying
|
360
|
+
end
|
361
|
+
|
362
|
+
event :land do
|
363
|
+
transition flying: :docked
|
364
|
+
end
|
365
|
+
end
|
366
|
+
|
367
|
+
# Mixed configuration: some machines async, others sync
|
368
|
+
state_machine :teleporter_status, async: true, initial: :offline do
|
369
|
+
event :power_up do
|
370
|
+
transition offline: :charging
|
371
|
+
end
|
372
|
+
|
373
|
+
event :teleport do
|
374
|
+
transition ready: :teleporting
|
375
|
+
end
|
376
|
+
end
|
377
|
+
|
378
|
+
# Weapons remain synchronous for safety
|
379
|
+
state_machine :weapons, initial: :disarmed do
|
380
|
+
event :arm do
|
381
|
+
transition disarmed: :armed
|
382
|
+
end
|
383
|
+
end
|
384
|
+
end
|
385
|
+
```
|
386
|
+
|
387
|
+
## Async Event Methods
|
388
|
+
|
389
|
+
Async-enabled machines automatically generate async versions of event methods:
|
390
|
+
|
391
|
+
```ruby
|
392
|
+
drone = AutonomousDrone.new
|
393
|
+
|
394
|
+
# Within an Async context
|
395
|
+
Async do
|
396
|
+
# Async event firing - returns Async::Task
|
397
|
+
task = drone.launch_async
|
398
|
+
result = task.wait # => true
|
399
|
+
|
400
|
+
# Bang methods for strict error handling
|
401
|
+
drone.power_up_async! # => Async::Task (raises on failure)
|
402
|
+
|
403
|
+
# Generic async event firing
|
404
|
+
drone.fire_event_async(:teleport) # => Async::Task
|
405
|
+
end
|
406
|
+
|
407
|
+
# Outside Async context - raises error
|
408
|
+
drone.launch_async # => RuntimeError: launch_async must be called within an Async context
|
409
|
+
```
|
410
|
+
|
411
|
+
## Thread Safety
|
412
|
+
|
413
|
+
Async state machines use enterprise-grade thread safety with `concurrent-ruby`:
|
414
|
+
|
415
|
+
```ruby
|
416
|
+
# Concurrent operations are automatically thread-safe
|
417
|
+
threads = []
|
418
|
+
10.times do
|
419
|
+
threads << Thread.new do
|
420
|
+
Async do
|
421
|
+
drone.launch_async.wait
|
422
|
+
drone.land_async.wait
|
423
|
+
end
|
424
|
+
end
|
425
|
+
end
|
426
|
+
threads.each(&:join)
|
427
|
+
```
|
428
|
+
|
429
|
+
## Performance Considerations
|
430
|
+
|
431
|
+
* **Thread Safety**: Uses `Concurrent::ReentrantReadWriteLock` for optimal read/write performance.
|
432
|
+
* **Memory**: Each async-enabled object gets its own mutex (lazy-loaded).
|
433
|
+
* **Marshalling**: Objects with async state machines can be serialized (mutex excluded/recreated).
|
434
|
+
* **Mixed Mode**: You can mix async and sync state machines in the same class.
|
435
|
+
|
436
|
+
## Dependencies
|
437
|
+
|
438
|
+
Async functionality requires:
|
439
|
+
|
440
|
+
```ruby
|
441
|
+
# Gemfile (automatically scoped to MRI Ruby)
|
442
|
+
platform :ruby do
|
443
|
+
gem 'async', '>= 2.25.0'
|
444
|
+
gem 'concurrent-ruby', '>= 1.3.5'
|
445
|
+
end
|
446
|
+
```
|
447
|
+
|
448
|
+
*Note: These gems are only installed on supported platforms. JRuby/TruffleRuby won't attempt installation.*
|
449
|
+
|
248
450
|
# Human-friendly names can be accessed for states/events
|
249
451
|
Vehicle.human_state_name(:first_gear) # => "first gear"
|
250
452
|
Vehicle.human_alarm_state_name(:active) # => "active"
|
@@ -22,7 +22,7 @@ end
|
|
22
22
|
|
23
23
|
# Load required gems with version constraints
|
24
24
|
gem 'async', '>= 2.25.0'
|
25
|
-
gem 'concurrent-ruby', '>= 1.3.5'
|
25
|
+
gem 'concurrent-ruby', '>= 1.3.5' # Security is not negotiable - enterprise-grade thread safety required
|
26
26
|
|
27
27
|
require 'async'
|
28
28
|
require 'concurrent-ruby'
|
@@ -34,6 +34,12 @@ module StateMachines
|
|
34
34
|
# Build conditionals
|
35
35
|
@if_condition = options.delete(:if)
|
36
36
|
@unless_condition = options.delete(:unless)
|
37
|
+
@if_state_condition = options.delete(:if_state)
|
38
|
+
@unless_state_condition = options.delete(:unless_state)
|
39
|
+
@if_all_states_condition = options.delete(:if_all_states)
|
40
|
+
@unless_all_states_condition = options.delete(:unless_all_states)
|
41
|
+
@if_any_state_condition = options.delete(:if_any_state)
|
42
|
+
@unless_any_state_condition = options.delete(:unless_any_state)
|
37
43
|
|
38
44
|
# Build event requirement
|
39
45
|
@event_requirement = build_matcher(options, :on, :except_on)
|
@@ -88,6 +94,9 @@ module StateMachines
|
|
88
94
|
def matches?(object, query = {})
|
89
95
|
!match(object, query).nil?
|
90
96
|
end
|
97
|
+
|
98
|
+
# Alias for Minitest's assert_match
|
99
|
+
alias =~ matches?
|
91
100
|
|
92
101
|
# Attempts to match the given object / query against the set of requirements
|
93
102
|
# configured for this branch. In addition to matching the event, from state,
|
@@ -182,21 +191,56 @@ module StateMachines
|
|
182
191
|
# Verifies that the conditionals for this branch evaluate to true for the
|
183
192
|
# given object. Event arguments are passed to guards that accept multiple parameters.
|
184
193
|
def matches_conditions?(object, query, event_args = [])
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
194
|
+
return true if query[:guard] == false
|
195
|
+
|
196
|
+
# Evaluate original if/unless conditions
|
197
|
+
if_passes = !if_condition || Array(if_condition).all? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
|
198
|
+
unless_passes = !unless_condition || Array(unless_condition).none? { |condition| evaluate_method_with_event_args(object, condition, event_args) }
|
199
|
+
|
200
|
+
return false unless if_passes && unless_passes
|
201
|
+
|
202
|
+
# Consolidate all state guards
|
203
|
+
state_guards = {
|
204
|
+
if_state: @if_state_condition,
|
205
|
+
unless_state: @unless_state_condition,
|
206
|
+
if_all_states: @if_all_states_condition,
|
207
|
+
unless_all_states: @unless_all_states_condition,
|
208
|
+
if_any_state: @if_any_state_condition,
|
209
|
+
unless_any_state: @unless_any_state_condition
|
210
|
+
}.compact
|
211
|
+
|
212
|
+
return true if state_guards.empty?
|
213
|
+
|
214
|
+
validate_and_check_state_guards(object, state_guards)
|
215
|
+
end
|
216
|
+
|
217
|
+
private
|
218
|
+
|
219
|
+
def validate_and_check_state_guards(object, guards)
|
220
|
+
guards.all? do |guard_type, conditions|
|
221
|
+
case guard_type
|
222
|
+
when :if_state, :if_all_states
|
223
|
+
conditions.all? { |machine, state| check_state(object, machine, state) }
|
224
|
+
when :unless_state
|
225
|
+
conditions.none? { |machine, state| check_state(object, machine, state) }
|
226
|
+
when :if_any_state
|
227
|
+
conditions.any? { |machine, state| check_state(object, machine, state) }
|
228
|
+
when :unless_all_states
|
229
|
+
!conditions.all? { |machine, state| check_state(object, machine, state) }
|
230
|
+
when :unless_any_state
|
231
|
+
conditions.none? { |machine, state| check_state(object, machine, state) }
|
232
|
+
end
|
199
233
|
end
|
200
234
|
end
|
235
|
+
|
236
|
+
def check_state(object, machine_name, state_name)
|
237
|
+
machine = object.class.state_machines[machine_name]
|
238
|
+
raise ArgumentError, "State machine '#{machine_name}' is not defined for #{object.class.name}" unless machine
|
239
|
+
|
240
|
+
state = machine.states[state_name]
|
241
|
+
raise ArgumentError, "State '#{state_name}' is not defined in state machine '#{machine_name}'" unless state
|
242
|
+
|
243
|
+
state.matches?(object.send(machine_name))
|
244
|
+
end
|
201
245
|
end
|
202
246
|
end
|
@@ -114,12 +114,10 @@ module StateMachines
|
|
114
114
|
# Input validation for string evaluation
|
115
115
|
validate_eval_string(str)
|
116
116
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
# Support for JRuby and Truffle Ruby, which don't support binding blocks
|
122
|
-
# Need to check with @headius, if jruby 10 does now.
|
117
|
+
# Evaluate the string in the object's context
|
118
|
+
if block_given?
|
119
|
+
# TruffleRuby and some other implementations need special handling for blocks
|
120
|
+
# Create a temporary method to evaluate the string with block support
|
123
121
|
eigen = class << object; self; end
|
124
122
|
eigen.class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
125
123
|
def __temp_eval_method__(*args, &b)
|
@@ -129,8 +127,8 @@ module StateMachines
|
|
129
127
|
result = object.__temp_eval_method__(*args, &block)
|
130
128
|
eigen.send(:remove_method, :__temp_eval_method__)
|
131
129
|
result
|
132
|
-
|
133
|
-
|
130
|
+
else
|
131
|
+
object.instance_eval(str, __FILE__, __LINE__)
|
134
132
|
end
|
135
133
|
else
|
136
134
|
raise ArgumentError, 'Methods must be a symbol denoting the method to call, a block to be invoked, or a string to be evaluated'
|
data/lib/state_machines/event.rb
CHANGED
@@ -104,7 +104,7 @@ module StateMachines
|
|
104
104
|
|
105
105
|
# Only a certain subset of explicit options are allowed for transition
|
106
106
|
# requirements
|
107
|
-
StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :except_from, :except_to, :if, :unless) if (options.keys - %i[from to on except_from except_to except_on if unless]).empty?
|
107
|
+
StateMachines::OptionsValidator.assert_valid_keys!(options, :from, :to, :except_from, :except_to, :if, :unless, :if_state, :unless_state, :if_all_states, :unless_all_states, :if_any_state, :unless_any_state) if (options.keys - %i[from to on except_from except_to except_on if unless if_state unless_state if_all_states unless_all_states if_any_state unless_any_state]).empty?
|
108
108
|
|
109
109
|
branches << branch = Branch.new(options.merge(on: name))
|
110
110
|
@known_states |= branch.known_states
|
@@ -119,12 +119,12 @@ module StateMachines
|
|
119
119
|
# TODO, simplify
|
120
120
|
# First try the regular event_transition
|
121
121
|
transition = machine.read(object, :event_transition)
|
122
|
-
|
122
|
+
|
123
123
|
# If not found and we have stored transitions by machine (issue #91)
|
124
124
|
if !transition && (transitions_by_machine = object.instance_variable_get(:@_state_machine_event_transitions))
|
125
125
|
transition = transitions_by_machine[machine.name]
|
126
126
|
end
|
127
|
-
|
127
|
+
|
128
128
|
transition || if event_name = machine.read(object, :event)
|
129
129
|
if event = self[event_name.to_sym, :name]
|
130
130
|
event.transition_for(object) || begin
|
@@ -30,7 +30,7 @@ module StateMachines
|
|
30
30
|
|
31
31
|
owner_class.include(StateMachines::AsyncMode::ThreadSafeState)
|
32
32
|
owner_class.include(StateMachines::AsyncMode::AsyncEvents)
|
33
|
-
|
33
|
+
extend(StateMachines::AsyncMode::AsyncMachine)
|
34
34
|
|
35
35
|
# Extend events to generate async versions
|
36
36
|
events.each do |event|
|
@@ -31,9 +31,7 @@ module StateMachines
|
|
31
31
|
machine.initial_state = options[:initial] if options.include?(:initial)
|
32
32
|
machine.owner_class = owner_class
|
33
33
|
# Configure async mode if requested in options
|
34
|
-
if options.include?(:async)
|
35
|
-
machine.configure_async_mode!(options[:async])
|
36
|
-
end
|
34
|
+
machine.configure_async_mode!(options[:async]) if options.include?(:async)
|
37
35
|
end
|
38
36
|
|
39
37
|
# Evaluate DSL
|
@@ -51,9 +51,7 @@ module StateMachines
|
|
51
51
|
instance_eval(&) if block_given?
|
52
52
|
|
53
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
|
54
|
+
configure_async_mode!(true) if @async_requested
|
57
55
|
|
58
56
|
self.initial_state = options[:initial] unless sibling_machines.any?
|
59
57
|
end
|
@@ -749,7 +749,6 @@ module StateMachines
|
|
749
749
|
has_async = object.respond_to?(async_method)
|
750
750
|
has_async_bang = object.respond_to?(async_bang_method)
|
751
751
|
|
752
|
-
default_message = "Expected async event methods #{async_method} and #{async_bang_method} to be available for event :#{event}"
|
753
752
|
|
754
753
|
if defined?(::Minitest)
|
755
754
|
assert has_async, "Missing #{async_method} method"
|
@@ -30,19 +30,15 @@ module StateMachines
|
|
30
30
|
# Whether the transition is only existing temporarily for the object
|
31
31
|
attr_writer :transient
|
32
32
|
|
33
|
-
# Determines whether the current ruby implementation supports pausing and
|
34
|
-
# resuming transitions
|
35
|
-
def self.pause_supported?
|
36
|
-
%w[ruby maglev].include?(RUBY_ENGINE)
|
37
|
-
end
|
38
|
-
|
39
33
|
# Creates a new, specific transition
|
40
34
|
def initialize(object, machine, event, from_name, to_name, read_state = true) # :nodoc:
|
41
35
|
@object = object
|
42
36
|
@machine = machine
|
43
37
|
@args = []
|
44
38
|
@transient = false
|
45
|
-
@
|
39
|
+
@paused_fiber = nil
|
40
|
+
@resuming = false
|
41
|
+
@continuation_block = nil
|
46
42
|
|
47
43
|
@event = machine.events.fetch(event)
|
48
44
|
@from_state = machine.states.fetch(from_name)
|
@@ -161,13 +157,13 @@ module StateMachines
|
|
161
157
|
# transition.perform(Time.now, run_action: false) # => Passes in additional arguments and only sets the state attribute
|
162
158
|
def perform(*args)
|
163
159
|
run_action = case args.last
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
160
|
+
in true | false
|
161
|
+
args.pop
|
162
|
+
in { run_action: }
|
163
|
+
args.last.delete(:run_action)
|
164
|
+
else
|
165
|
+
true
|
166
|
+
end
|
171
167
|
|
172
168
|
self.args = args
|
173
169
|
|
@@ -195,14 +191,32 @@ module StateMachines
|
|
195
191
|
# callbacks will not have an effect on the result.
|
196
192
|
def run_callbacks(options = {}, &block)
|
197
193
|
options = { before: true, after: true }.merge(options)
|
198
|
-
@success = false
|
199
194
|
|
200
|
-
|
195
|
+
# If we have a paused fiber and we're not trying to resume (after: false),
|
196
|
+
# this is an idempotent call on an already-paused transition. Just return true.
|
197
|
+
return true if @paused_fiber&.alive? && !options[:after]
|
198
|
+
|
199
|
+
# Extract pausable options
|
200
|
+
pausable_options = options.key?(:fiber) ? { fiber: options[:fiber] } : {}
|
201
|
+
|
202
|
+
# Check if we're resuming from a pause
|
203
|
+
if @paused_fiber&.alive? && options[:after]
|
204
|
+
# Resume the paused fiber
|
205
|
+
# Don't reset @success when resuming - preserve the state from the pause
|
206
|
+
# Store the block for later execution
|
207
|
+
@continuation_block = block if block_given?
|
208
|
+
halted = pausable(pausable_options) { true }
|
209
|
+
else
|
210
|
+
@success = false
|
211
|
+
# For normal execution (not pause/resume), default to success
|
212
|
+
# The action block will override this if needed
|
213
|
+
halted = pausable(pausable_options) { before(options[:after], &block) } if options[:before]
|
214
|
+
end
|
201
215
|
|
202
216
|
# After callbacks are only run if:
|
203
|
-
# * An around callback didn't halt after yielding
|
217
|
+
# * An around callback didn't halt after yielding OR the run failed
|
204
218
|
# * They're enabled or the run didn't succeed
|
205
|
-
after if !(@before_run && halted) && (options[:after] || !@success)
|
219
|
+
after if (!(@before_run && halted) || !@success) && (options[:after] || !@success)
|
206
220
|
|
207
221
|
@before_run
|
208
222
|
end
|
@@ -266,7 +280,9 @@ module StateMachines
|
|
266
280
|
# the state has already been persisted
|
267
281
|
def reset
|
268
282
|
@before_run = @persisted = @after_run = false
|
269
|
-
@
|
283
|
+
@paused_fiber = nil
|
284
|
+
@resuming = false
|
285
|
+
@continuation_block = nil
|
270
286
|
end
|
271
287
|
|
272
288
|
# Determines equality of transitions by testing whether the object, states,
|
@@ -290,6 +306,38 @@ module StateMachines
|
|
290
306
|
"#<#{self.class} #{%w[attribute event from from_name to to_name].map { |attr| "#{attr}=#{send(attr).inspect}" } * ' '}>"
|
291
307
|
end
|
292
308
|
|
309
|
+
# Checks whether this transition is currently paused.
|
310
|
+
# Returns true if there is a paused fiber, false otherwise.
|
311
|
+
def paused?
|
312
|
+
@paused_fiber&.alive? || false
|
313
|
+
end
|
314
|
+
|
315
|
+
# Checks whether this transition has a paused fiber that can be resumed.
|
316
|
+
# Returns true if there is a paused fiber, false otherwise.
|
317
|
+
#
|
318
|
+
# Note: The actual resuming happens automatically when run_callbacks is called
|
319
|
+
# again on a transition with a paused fiber.
|
320
|
+
def resumable?
|
321
|
+
paused?
|
322
|
+
end
|
323
|
+
|
324
|
+
# Manually resumes the execution of a previously paused callback.
|
325
|
+
# Returns true if the transition was successfully resumed and completed,
|
326
|
+
# false if there was no paused fiber, and raises an exception if the
|
327
|
+
# transition was halted.
|
328
|
+
def resume!(&block)
|
329
|
+
return false unless paused?
|
330
|
+
|
331
|
+
# Store continuation block if provided
|
332
|
+
@continuation_block = block if block_given?
|
333
|
+
|
334
|
+
# Run the pausable block which will resume the fiber
|
335
|
+
halted = pausable { true }
|
336
|
+
|
337
|
+
# Return whether the transition completed successfully
|
338
|
+
!halted
|
339
|
+
end
|
340
|
+
|
293
341
|
private
|
294
342
|
|
295
343
|
# Runs a block that may get paused. If the block doesn't pause, then
|
@@ -298,20 +346,97 @@ module StateMachines
|
|
298
346
|
#
|
299
347
|
# This will return true if the given block halts for a reason other than
|
300
348
|
# getting paused.
|
301
|
-
|
302
|
-
|
349
|
+
#
|
350
|
+
# Options:
|
351
|
+
# * :fiber - Whether to use fiber-based execution (default: true)
|
352
|
+
def pausable(options = {})
|
353
|
+
# If fiber is disabled, use simple synchronous execution
|
354
|
+
if options[:fiber] == false
|
303
355
|
halted = !catch(:halt) do
|
304
356
|
yield
|
305
357
|
true
|
306
358
|
end
|
307
|
-
|
308
|
-
raise unless @resume_block
|
359
|
+
return halted
|
309
360
|
end
|
310
361
|
|
311
|
-
if @
|
312
|
-
|
362
|
+
if @paused_fiber
|
363
|
+
# Resume the paused fiber
|
364
|
+
@resuming = true
|
365
|
+
begin
|
366
|
+
result = @paused_fiber.resume
|
367
|
+
rescue StandardError => e
|
368
|
+
# Clean up on exception
|
369
|
+
@resuming = false
|
370
|
+
@paused_fiber = nil
|
371
|
+
raise e
|
372
|
+
end
|
373
|
+
@resuming = false
|
374
|
+
|
375
|
+
# Handle different result types
|
376
|
+
case result
|
377
|
+
when Array
|
378
|
+
# Exception occurred inside the fiber
|
379
|
+
if result[0] == :error
|
380
|
+
# Clean up state before re-raising
|
381
|
+
@paused_fiber = nil
|
382
|
+
raise result[1]
|
383
|
+
end
|
384
|
+
else
|
385
|
+
# Normal flow
|
386
|
+
# Check if fiber is still alive after resume
|
387
|
+
if @paused_fiber.alive?
|
388
|
+
# Still paused, keep the fiber
|
389
|
+
true
|
390
|
+
else
|
391
|
+
# Fiber completed
|
392
|
+
@paused_fiber = nil
|
393
|
+
result == :halted
|
394
|
+
end
|
395
|
+
end
|
313
396
|
else
|
314
|
-
|
397
|
+
# Create a new fiber to run the block
|
398
|
+
fiber = Fiber.new do
|
399
|
+
# Mark that we're inside a pausable fiber
|
400
|
+
Thread.current[:state_machine_fiber_pausable] = true
|
401
|
+
begin
|
402
|
+
halted = !catch(:halt) do
|
403
|
+
yield
|
404
|
+
true
|
405
|
+
end
|
406
|
+
halted ? :halted : :completed
|
407
|
+
rescue StandardError => e
|
408
|
+
# Store the exception for re-raising
|
409
|
+
[:error, e]
|
410
|
+
ensure
|
411
|
+
# Clean up the flag
|
412
|
+
Thread.current[:state_machine_fiber_pausable] = false
|
413
|
+
end
|
414
|
+
end
|
415
|
+
|
416
|
+
# Run the fiber
|
417
|
+
result = fiber.resume
|
418
|
+
|
419
|
+
# Handle different result types
|
420
|
+
case result
|
421
|
+
when Array
|
422
|
+
# Exception occurred
|
423
|
+
if result[0] == :error
|
424
|
+
# Clean up state before re-raising
|
425
|
+
@paused_fiber = nil
|
426
|
+
raise result[1]
|
427
|
+
end
|
428
|
+
else
|
429
|
+
# Normal flow
|
430
|
+
# Save if paused
|
431
|
+
if fiber.alive?
|
432
|
+
@paused_fiber = fiber
|
433
|
+
# Return true to indicate paused (treated as halted for flow control)
|
434
|
+
true
|
435
|
+
else
|
436
|
+
# Fiber completed, return whether it was halted
|
437
|
+
result == :halted
|
438
|
+
end
|
439
|
+
end
|
315
440
|
end
|
316
441
|
end
|
317
442
|
|
@@ -319,34 +444,22 @@ module StateMachines
|
|
319
444
|
# around callbacks when the remainder of the callback will be executed at
|
320
445
|
# a later point in time.
|
321
446
|
def pause
|
322
|
-
|
323
|
-
|
324
|
-
return if @resume_block
|
325
|
-
|
326
|
-
require 'continuation' unless defined?(callcc)
|
327
|
-
callcc do |block|
|
328
|
-
@paused_block = block
|
329
|
-
throw :halt, true
|
330
|
-
end
|
331
|
-
end
|
447
|
+
# Don't pause if we're in the middle of resuming
|
448
|
+
return if @resuming
|
332
449
|
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
if @paused_block
|
337
|
-
halted, error = callcc do |block|
|
338
|
-
@resume_block = block
|
339
|
-
@paused_block.call
|
340
|
-
end
|
450
|
+
# Only yield if we're actually inside a fiber created by pausable
|
451
|
+
# We use a thread-local variable to track this
|
452
|
+
return unless Thread.current[:state_machine_fiber_pausable]
|
341
453
|
|
342
|
-
|
454
|
+
Fiber.yield
|
343
455
|
|
344
|
-
|
456
|
+
# When we resume from the pause, execute the continuation block if present
|
457
|
+
return unless @continuation_block && !@result
|
345
458
|
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
459
|
+
action = { success: true }.merge(@continuation_block.call)
|
460
|
+
@result = action[:result]
|
461
|
+
@success = action[:success]
|
462
|
+
@continuation_block = nil
|
350
463
|
end
|
351
464
|
|
352
465
|
# Runs the machine's +before+ callbacks for this transition. Only
|
@@ -356,36 +469,51 @@ module StateMachines
|
|
356
469
|
# Once the callbacks are run, they cannot be run again until this transition
|
357
470
|
# is reset.
|
358
471
|
def before(complete = true, index = 0, &block)
|
359
|
-
|
360
|
-
|
361
|
-
|
472
|
+
return if @before_run
|
473
|
+
|
474
|
+
callback = machine.callbacks[:before][index]
|
362
475
|
|
476
|
+
if callback
|
477
|
+
# Check if callback matches this transition using branch
|
478
|
+
if callback.branch.matches?(object, context)
|
363
479
|
if callback.type == :around
|
364
480
|
# Around callback: need to handle recursively. Execution only gets
|
365
481
|
# paused if:
|
366
482
|
# * The block fails and the callback doesn't run on failures OR
|
367
483
|
# * The block succeeds, but after callbacks are disabled (in which
|
368
484
|
# case a continuation is stored for later execution)
|
369
|
-
|
370
|
-
|
371
|
-
|
485
|
+
callback.call(object, context, self) do
|
486
|
+
before(complete, index + 1, &block)
|
487
|
+
|
488
|
+
pause if @success && !complete
|
372
489
|
|
373
|
-
|
374
|
-
|
375
|
-
|
490
|
+
# If the block failed (success is false), we should halt
|
491
|
+
# the around callback from continuing
|
492
|
+
throw :halt unless @success
|
376
493
|
end
|
377
494
|
else
|
378
495
|
# Normal before callback
|
379
496
|
callback.call(object, context, self)
|
497
|
+
# Continue with next callback
|
498
|
+
before(complete, index + 1, &block)
|
380
499
|
end
|
500
|
+
else
|
501
|
+
# Skip to next callback if it doesn't match
|
502
|
+
before(complete, index + 1, &block)
|
503
|
+
end
|
504
|
+
else
|
505
|
+
# No more callbacks, execute the action block if at the end
|
506
|
+
if block_given?
|
507
|
+
action = { success: true }.merge(yield)
|
508
|
+
@result = action[:result]
|
509
|
+
@success = action[:success]
|
510
|
+
else
|
511
|
+
# No action block provided, default to success
|
512
|
+
@success = true
|
381
513
|
end
|
382
514
|
|
383
515
|
@before_run = true
|
384
516
|
end
|
385
|
-
|
386
|
-
action = { success: true }.merge(block_given? ? yield : {})
|
387
|
-
@result = action[:result]
|
388
|
-
@success = action[:success]
|
389
517
|
end
|
390
518
|
|
391
519
|
# Runs the machine's +after+ callbacks for this transition. Only
|
@@ -404,12 +532,9 @@ module StateMachines
|
|
404
532
|
def after
|
405
533
|
return if @after_run
|
406
534
|
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
type = @success ? :after : :failure
|
411
|
-
machine.callbacks[type].each { |callback| callback.call(object, context, self) }
|
412
|
-
end
|
535
|
+
catch(:halt) do
|
536
|
+
type = @success ? :after : :failure
|
537
|
+
machine.callbacks[type].each { |callback| callback.call(object, context, self) }
|
413
538
|
end
|
414
539
|
|
415
540
|
@after_run = true
|
@@ -14,6 +14,9 @@ module StateMachines
|
|
14
14
|
# Whether transitions should wrapped around a transaction block
|
15
15
|
attr_reader :use_transactions
|
16
16
|
|
17
|
+
# Options passed to the collection
|
18
|
+
attr_reader :options
|
19
|
+
|
17
20
|
# Creates a new collection of transitions that can be run in parallel. Each
|
18
21
|
# transition *must* be for a different attribute.
|
19
22
|
#
|
@@ -26,16 +29,23 @@ module StateMachines
|
|
26
29
|
|
27
30
|
# Determine the validity of the transitions as a whole
|
28
31
|
@valid = all?
|
29
|
-
reject!
|
32
|
+
reject!(&:!)
|
30
33
|
|
31
|
-
attributes = map
|
34
|
+
attributes = map(&:attribute).uniq
|
32
35
|
raise ArgumentError, 'Cannot perform multiple transitions in parallel for the same state machine attribute' if attributes.length != length
|
33
36
|
|
34
|
-
StateMachines::OptionsValidator.assert_valid_keys!(options, :actions, :after, :use_transactions)
|
37
|
+
StateMachines::OptionsValidator.assert_valid_keys!(options, :actions, :after, :use_transactions, :fiber)
|
35
38
|
options = { actions: true, after: true, use_transactions: true }.merge(options)
|
36
39
|
@skip_actions = !options[:actions]
|
37
40
|
@skip_after = !options[:after]
|
38
41
|
@use_transactions = options[:use_transactions]
|
42
|
+
@options = options
|
43
|
+
|
44
|
+
# Reset transitions when creating a new collection
|
45
|
+
# But preserve paused transitions to allow resuming
|
46
|
+
each do |transition|
|
47
|
+
transition.reset unless transition.paused?
|
48
|
+
end
|
39
49
|
end
|
40
50
|
|
41
51
|
# Runs each of the collection's transitions in parallel.
|
@@ -104,7 +114,7 @@ module StateMachines
|
|
104
114
|
# Gets the list of actions to run. If configured to skip actions, then
|
105
115
|
# this will return an empty collection.
|
106
116
|
def actions
|
107
|
-
empty? ? [nil] : map
|
117
|
+
empty? ? [nil] : map(&:action).uniq
|
108
118
|
end
|
109
119
|
|
110
120
|
# Determines whether an event attribute be used to trigger the transitions
|
@@ -127,11 +137,21 @@ module StateMachines
|
|
127
137
|
#
|
128
138
|
# If any transition fails to run its callbacks, :halt will be thrown.
|
129
139
|
def run_callbacks(index = 0, &block)
|
130
|
-
if transition = self[index]
|
131
|
-
|
140
|
+
if (transition = self[index])
|
141
|
+
# Pass through any options that affect callback execution (e.g., fiber: false)
|
142
|
+
callback_options = { after: !skip_after }
|
143
|
+
callback_options[:fiber] = options[:fiber] if options.key?(:fiber)
|
144
|
+
|
145
|
+
callback_result = transition.run_callbacks(callback_options) do
|
132
146
|
run_callbacks(index + 1, &block)
|
133
147
|
{ result: results[transition.action], success: success? }
|
134
148
|
end
|
149
|
+
|
150
|
+
# If we're skipping after callbacks and the transition is paused,
|
151
|
+
# consider it successful (the pause was intentional)
|
152
|
+
@success = true if skip_after && transition.paused?
|
153
|
+
|
154
|
+
throw :halt unless callback_result
|
135
155
|
else
|
136
156
|
persist
|
137
157
|
run_actions(&block)
|
@@ -141,7 +161,7 @@ module StateMachines
|
|
141
161
|
# Transitions the current value of the object's states to those specified by
|
142
162
|
# each transition
|
143
163
|
def persist
|
144
|
-
each
|
164
|
+
each(&:persist)
|
145
165
|
end
|
146
166
|
|
147
167
|
# Runs the actions for each transition. If a block is given method, then it
|
@@ -163,7 +183,7 @@ module StateMachines
|
|
163
183
|
|
164
184
|
# Rolls back changes made to the object's states via each transition
|
165
185
|
def rollback
|
166
|
-
each
|
186
|
+
each(&:rollback)
|
167
187
|
end
|
168
188
|
|
169
189
|
# Wraps the given block with a rescue handler so that any exceptions that
|
@@ -202,14 +222,14 @@ module StateMachines
|
|
202
222
|
# Hooks into running transition callbacks so that event / event transition
|
203
223
|
# attributes can be properly updated
|
204
224
|
def run_callbacks(index = 0)
|
205
|
-
if index
|
225
|
+
if index.zero?
|
206
226
|
# Clears any traces of the event attribute to prevent it from being
|
207
227
|
# evaluated multiple times if actions are nested
|
208
228
|
each do |transition|
|
209
229
|
transition.machine.write(object, :event, nil)
|
210
230
|
transition.machine.write(object, :event_transition, nil)
|
211
231
|
end
|
212
|
-
|
232
|
+
|
213
233
|
# Clear stored transitions hash for new cycle (issue #91)
|
214
234
|
if !empty? && (obj = first.object)
|
215
235
|
obj.instance_variable_set(:@_state_machine_event_transitions, nil)
|
@@ -229,9 +249,9 @@ module StateMachines
|
|
229
249
|
# after callbacks.
|
230
250
|
if skip_after && success?
|
231
251
|
each { |transition| transition.machine.write(object, :event_transition, transition) }
|
232
|
-
|
252
|
+
|
233
253
|
# Store transitions in a hash by machine name to avoid overwriting (issue #91)
|
234
|
-
|
254
|
+
unless empty?
|
235
255
|
transitions_by_machine = object.instance_variable_get(:@_state_machine_event_transitions) || {}
|
236
256
|
each { |transition| transitions_by_machine[transition.machine.name] = transition }
|
237
257
|
object.instance_variable_set(:@_state_machine_event_transitions, transitions_by_machine)
|