state_machines 0.50.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 247ae1ee6fa7beb6ac29a68dfd400ea41a3923b973d842a5adb2ef78960b3266
4
- data.tar.gz: 7240a29d3d87740ade0d0197e9c4c0192176023f23e453d1ba659e8be905afef
3
+ metadata.gz: c2e1765c263d1be7747cf812e195b95ab82d183dbf62d83a4014bc47e34132ee
4
+ data.tar.gz: 35fc3eba5b2a7321436de83091467b0f9e65309356e7d948479acd46be85ee68
5
5
  SHA512:
6
- metadata.gz: 26e8c0a197cdc254c4157a57e3bcfabfef87240280555ae623d993e30ccb68ba6433e911da171f60beb0e756af54aa60ff18395c9d49c6719a06b22bd895bdca
7
- data.tar.gz: 9dfbdceba5b428f415dfeeba91995ab52640282b2b6c2fb92dea266c97677263331706e6b37f09a6280dc3f578da27dae1f5ade628a655342e46c565ddbe05db
6
+ metadata.gz: 7b76d0c6596909901ccbeedf46b581b0fda2289466ae9c0bd402dc8b40db7d39fe74cbe7792a3b49c439e920ac6dd92c6bedfb51236155a3d0e61b0d7a84db51
7
+ data.tar.gz: 887b286630bd17fb51c9b00550ee3c6822fd3d1b0c2084630d4cf3e960c66abda12962e050bd09c1829e7091694d120378ad15607ba02f1f2594518b15173976
@@ -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' # Security is not negotiable - enterprise-grade thread safety required
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'
@@ -94,6 +94,9 @@ module StateMachines
94
94
  def matches?(object, query = {})
95
95
  !match(object, query).nil?
96
96
  end
97
+
98
+ # Alias for Minitest's assert_match
99
+ alias =~ matches?
97
100
 
98
101
  # Attempts to match the given object / query against the set of requirements
99
102
  # configured for this branch. In addition to matching the event, from state,
@@ -114,12 +114,10 @@ module StateMachines
114
114
  # Input validation for string evaluation
115
115
  validate_eval_string(str)
116
116
 
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.
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
- in [false, _]
133
- eval(str, object.instance_eval { binding })
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'
@@ -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
- self.extend(StateMachines::AsyncMode::AsyncMachine)
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
@@ -565,8 +565,6 @@ module StateMachines
565
565
  end
566
566
  end
567
567
  else
568
- # Validate string input before eval if method is a string
569
- validate_eval_string(method) if method.is_a?(String)
570
568
  helper_module.class_eval(method, __FILE__, __LINE__)
571
569
  end
572
570
  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
- @resume_block = nil
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
- in true | false
165
- args.pop
166
- in { run_action: }
167
- args.last.delete(:run_action)
168
- else
169
- true
170
- end
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
- halted = pausable { before(options[:after], &block) } if options[:before]
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
- @paused_block = nil
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
- def pausable
302
- begin
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
- rescue StandardError => e
308
- raise unless @resume_block
359
+ return halted
309
360
  end
310
361
 
311
- if @resume_block
312
- @resume_block.call(halted, e)
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
- halted
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
- raise ArgumentError, 'around_transition callbacks cannot be called in multiple execution contexts in java implementations of Ruby. Use before/after_transitions instead.' unless self.class.pause_supported?
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
- # Resumes the execution of a previously paused callback execution. Once
334
- # the paused callbacks complete, the current execution will continue.
335
- def resume
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
- @resume_block = @paused_block = nil
454
+ Fiber.yield
343
455
 
344
- raise error if error
456
+ # When we resume from the pause, execute the continuation block if present
457
+ return unless @continuation_block && !@result
345
458
 
346
- !halted
347
- else
348
- true
349
- end
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
- unless @before_run
360
- while callback = machine.callbacks[:before][index]
361
- index += 1
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
- return if catch(:cancel) do
370
- callback.call(object, context, self) do
371
- before(complete, index, &block)
485
+ callback.call(object, context, self) do
486
+ before(complete, index + 1, &block)
487
+
488
+ pause if @success && !complete
372
489
 
373
- pause if @success && !complete
374
- throw :cancel, true unless @success
375
- end
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
- # First resume previously paused callbacks
408
- if resume
409
- catch(:halt) do
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! { |transition| !transition }
32
+ reject!(&:!)
30
33
 
31
- attributes = map { |transition| transition.attribute }.uniq
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 { |transition| transition.action }.uniq
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
- throw :halt unless transition.run_callbacks(after: !skip_after) do
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 { |transition| transition.persist }
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 { |transition| transition.rollback }
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 == 0
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
- if !empty?
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StateMachines
4
- VERSION = '0.50.0'
4
+ VERSION = '0.100.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: state_machines
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.50.0
4
+ version: 0.100.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih