state_machines 0.31.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcd04394640ccea1429d2e121a46e2c705f8c16b2987b142858ad2f151c9c287
4
- data.tar.gz: 58cfb25ff972b1b2802730d9084d3c3b27c59a82e694646ce676e38fc661cbb9
3
+ metadata.gz: 247ae1ee6fa7beb6ac29a68dfd400ea41a3923b973d842a5adb2ef78960b3266
4
+ data.tar.gz: 7240a29d3d87740ade0d0197e9c4c0192176023f23e453d1ba659e8be905afef
5
5
  SHA512:
6
- metadata.gz: c9328eea4f9d8d9e57124571de54bb8f5160a044ef913d3e515bcaac933b7355ccf987c10699c71e94145f44bc1e2387342f350dcbff0100d76a5c1c06645321
7
- data.tar.gz: 07c7bfad14f7ce103eec5d6dfa48a9117bc0a73cb5433447c4bf7b6cdea1b87b894a12ad6b85b5a0cb05d63dd68e291f980eeca40e60754c13d5b5c44a7fcf29
6
+ metadata.gz: 26e8c0a197cdc254c4157a57e3bcfabfef87240280555ae623d993e30ccb68ba6433e911da171f60beb0e756af54aa60ff18395c9d49c6719a06b22bd895bdca
7
+ data.tar.gz: 9dfbdceba5b428f415dfeeba91995ab52640282b2b6c2fb92dea266c97677263331706e6b37f09a6280dc3f578da27dae1f5ade628a655342e46c565ddbe05db
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"
@@ -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