ruby_reactor 0.8.4 → 0.8.5

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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/CHANGELOG.md +196 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +47 -11
  7. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  8. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +12 -6
  9. data/lib/ruby_reactor/dsl/compose_builder.rb +12 -6
  10. data/lib/ruby_reactor/dsl/interrupt_builder.rb +1 -3
  11. data/lib/ruby_reactor/dsl/map_builder.rb +0 -2
  12. data/lib/ruby_reactor/dsl/step_builder.rb +91 -19
  13. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  14. data/lib/ruby_reactor/error/rescuable.rb +28 -0
  15. data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
  16. data/lib/ruby_reactor/executor/result_handler.rb +18 -16
  17. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  18. data/lib/ruby_reactor/executor/step_executor.rb +59 -49
  19. data/lib/ruby_reactor/executor.rb +38 -4
  20. data/lib/ruby_reactor/map/collector.rb +21 -11
  21. data/lib/ruby_reactor/map/dispatcher.rb +29 -3
  22. data/lib/ruby_reactor/map/element_executor.rb +9 -3
  23. data/lib/ruby_reactor/map/helpers.rb +32 -2
  24. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  25. data/lib/ruby_reactor/reactor.rb +24 -0
  26. data/lib/ruby_reactor/rspec/matchers.rb +19 -3
  27. data/lib/ruby_reactor/step/compose_step.rb +7 -1
  28. data/lib/ruby_reactor/step/map_step.rb +109 -4
  29. data/lib/ruby_reactor/step.rb +7 -0
  30. data/lib/ruby_reactor/step_worker.rb +46 -22
  31. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  32. data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
  33. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor/web/api.rb +1 -1
  36. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
  37. data/lib/ruby_reactor/web/public/index.html +1 -1
  38. data/lib/ruby_reactor/worker.rb +3 -1
  39. data/lib/ruby_reactor.rb +17 -6
  40. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  41. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  42. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  43. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  44. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  45. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  46. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  47. data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
  48. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  49. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  50. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  51. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
  52. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  53. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  54. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  55. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  56. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  57. data/specs/007-execution-flow-analysis/plan.md +127 -0
  58. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  59. data/specs/007-execution-flow-analysis/research.md +202 -0
  60. data/specs/007-execution-flow-analysis/spec.md +270 -0
  61. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  62. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  63. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  64. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  65. data/specs/008-rollback-reliability/data-model.md +139 -0
  66. data/specs/008-rollback-reliability/plan.md +233 -0
  67. data/specs/008-rollback-reliability/quickstart.md +105 -0
  68. data/specs/008-rollback-reliability/research.md +653 -0
  69. data/specs/008-rollback-reliability/spec.md +561 -0
  70. data/specs/008-rollback-reliability/tasks.md +1110 -0
  71. data/specs/future_improvements.md +48 -0
  72. metadata +35 -2
@@ -13,7 +13,7 @@ module RubyReactor
13
13
  period_config: "with_period", ordered_lock_config: "with_ordered_lock"
14
14
  }.freeze
15
15
 
16
- attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
16
+ attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
17
17
  :dependencies, :args_validator, :output_validator
18
18
 
19
19
  def initialize(name, impl = nil, reactor = nil)
@@ -24,8 +24,6 @@ module RubyReactor
24
24
  @run_block = nil
25
25
  @compensate_block = nil
26
26
  @undo_block = nil
27
- @conditions = []
28
- @guards = []
29
27
  @dependencies = []
30
28
  @arg_validations = []
31
29
  @validate_args_input = nil
@@ -78,12 +76,15 @@ module RubyReactor
78
76
  @undo_block = block
79
77
  end
80
78
 
81
- def where(&predicate)
82
- @conditions << predicate
79
+ # `where`/`guard` were a second, pre-body skip mechanism with failure
80
+ # rules of their own (008 R-15). A step decides to skip itself from its
81
+ # body instead, so both stay only to name the replacement.
82
+ def where(*)
83
+ raise_removed_condition!(:where)
83
84
  end
84
85
 
85
- def guard(&guard_fn)
86
- @guards << guard_fn
86
+ def guard(*)
87
+ raise_removed_condition!(:guard)
87
88
  end
88
89
 
89
90
  def wait_for(*step_names)
@@ -148,8 +149,6 @@ module RubyReactor
148
149
  run_block: @run_block,
149
150
  compensate_block: @compensate_block,
150
151
  undo_block: @undo_block,
151
- conditions: @conditions,
152
- guards: @guards,
153
152
  dependencies: @dependencies,
154
153
  args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
155
154
  output_validator: @output_validator,
@@ -167,6 +166,15 @@ module RubyReactor
167
166
 
168
167
  private
169
168
 
169
+ def raise_removed_condition!(keyword)
170
+ raise RubyReactor::Error::DeprecatedDslError.new(
171
+ "`#{keyword}` inside a step block has been removed (`where`/`guard` are gone). To skip " \
172
+ ":#{@name}, return `Skipped(value)` (or call `skip!(value)`) from its `run` body; the " \
173
+ "reactor continues with that value, exactly as for a Success.",
174
+ step: @name
175
+ )
176
+ end
177
+
170
178
  # The same primitive declared BOTH inline and on the step class is
171
179
  # ambiguous — two keys for one slot of the fixed acquisition order, and
172
180
  # `StepConfig`'s readers would silently let the inline one win. Refuse it
@@ -237,11 +245,15 @@ module RubyReactor
237
245
  end
238
246
 
239
247
  def warn_deprecation(site, message)
248
+ warn_definition(site, "DEPRECATION:", "#{message} Removal no earlier than the next MAJOR.")
249
+ end
250
+
251
+ # A definition-time warning, printed once per declaration site.
252
+ def warn_definition(site, prefix, message)
240
253
  location = "#{site.path}:#{site.lineno}"
241
254
  return unless StepBuilder.deprecation_sites.add?(location)
242
255
 
243
- warn "[RubyReactor] DEPRECATION: #{location} #{reactor_label} #{message} " \
244
- "Removal no earlier than the next MAJOR."
256
+ warn ["[RubyReactor]", prefix, location, reactor_label, message].compact.join(" ")
245
257
  end
246
258
 
247
259
  def owned_contract
@@ -256,7 +268,7 @@ module RubyReactor
256
268
  class StepConfig
257
269
  NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }.freeze
258
270
 
259
- attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
271
+ attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
260
272
  :dependencies, :args_validator, :output_validator, :async_dispatch,
261
273
  :inline_contract
262
274
 
@@ -268,8 +280,6 @@ module RubyReactor
268
280
  @run_block = config[:run_block]
269
281
  @compensate_block = config[:compensate_block]
270
282
  @undo_block = config[:undo_block]
271
- @conditions = config[:conditions] || []
272
- @guards = config[:guards] || []
273
283
  @dependencies = config[:dependencies] || []
274
284
  @args_validator = config[:args_validator]
275
285
  @output_validator = config[:output_validator]
@@ -358,6 +368,51 @@ module RubyReactor
358
368
  input_contract ? input_contract.enforce!(args) : args
359
369
  end
360
370
 
371
+ # The step's `argument` wiring resolved against `context`: each source,
372
+ # then its `transform`. The one copy every process uses (StepExecutor,
373
+ # StepWorker). Any raise becomes an `ArgumentResolutionError`, a
374
+ # never-started failure attributed to this step (008 R-06) — except a
375
+ # park signal (a worker waiting on an async result), which is not a failure.
376
+ def resolve_arguments(context)
377
+ arguments.to_h do |arg_name, arg_config|
378
+ value = arg_config[:source].resolve(context)
379
+ value = arg_config[:transform].call(value) if arg_config[:transform]
380
+ [arg_name, value]
381
+ end
382
+ rescue Error::ExecutionParked
383
+ raise
384
+ rescue Error::Rescuable => e
385
+ raise never_started(Error::ArgumentResolutionError, "could not resolve its arguments", e)
386
+ end
387
+
388
+ # Rollback dispatch, the same wherever the step rolls back
389
+ # (CompensationManager, StepWorker): the inline block, else the class
390
+ # step, else Skipped — nothing defined, rollback continues. Coordination,
391
+ # trace and middleware stay with the caller.
392
+ def call_compensate(error, arguments, context)
393
+ catch(StepSignals::TAG) do
394
+ if compensate_block
395
+ compensate_block.call(error, wrap_inputs(arguments), context)
396
+ elsif has_impl?
397
+ impl.compensate(error, arguments, context)
398
+ else
399
+ RubyReactor.Skipped()
400
+ end
401
+ end
402
+ end
403
+
404
+ def call_undo(result_value, arguments, context)
405
+ catch(StepSignals::TAG) do
406
+ if undo_block
407
+ undo_block.call(result_value, wrap_inputs(arguments), context)
408
+ elsif has_impl?
409
+ impl.undo(result_value, arguments, context)
410
+ else
411
+ RubyReactor.Skipped()
412
+ end
413
+ end
414
+ end
415
+
361
416
  # The step's work as a reactor runs it. Coordination is NOT taken here:
362
417
  # the caller (StepExecutor / StepWorker) takes this config's effective
363
418
  # declarations — inline and class alike — in one fixed order around it.
@@ -381,6 +436,20 @@ module RubyReactor
381
436
  !@async_dispatch.nil?
382
437
  end
383
438
 
439
+ # Whether a success of this step is recorded for undo. `async_step` /
440
+ # `async_reactor` dispatches are independent units of work with their
441
+ # own compensation flows: the parent rolling back must not "undo" a
442
+ # dispatch whose unit runs (and may still succeed) elsewhere (008 R-10).
443
+ def rollback_tracked?
444
+ !async_dispatch?
445
+ end
446
+
447
+ # Whether a run of this step cut short by an interruption is still
448
+ # tracked for undo: the construct's own definition decides (008 R-16).
449
+ def undoes_partial_run?
450
+ rollback_tracked? && has_impl? && impl.respond_to?(:undoes_partial_run?) && impl.undoes_partial_run?
451
+ end
452
+
384
453
  def has_impl?
385
454
  !@impl.nil?
386
455
  end
@@ -403,14 +472,17 @@ module RubyReactor
403
472
  (retry_config[:max_attempts] || 0) > 1
404
473
  end
405
474
 
406
- def should_run?(context)
407
- @conditions.all? { |condition| condition.call(context) } &&
408
- @guards.all? { |guard| guard.call(context) }
409
- end
410
-
411
475
  def interrupt?
412
476
  false
413
477
  end
478
+
479
+ private
480
+
481
+ def never_started(error_class, what, cause)
482
+ error = error_class.new("Step '#{name}' #{what}: #{cause.message}", step: name, original_error: cause)
483
+ error.set_backtrace(cause.backtrace)
484
+ error
485
+ end
414
486
  end
415
487
  end
416
488
  end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Error
5
+ # A step's `argument` source, `transform` or result path raised. The step's
6
+ # body never started, so it is never compensated; completed steps are
7
+ # undone (008 R-06). The same inputs fail the same way, so never retried.
8
+ class ArgumentResolutionError < Base
9
+ attr_reader :exception_class
10
+
11
+ def initialize(message, step:, original_error:, context: nil)
12
+ super(message, step: step, context: context, original_error: original_error)
13
+ @exception_class = original_error.class.name
14
+ end
15
+
16
+ def retryable? = false
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Error
5
+ # `rescue Error::Rescuable` catches every exception reactor code can raise —
6
+ # standard or not (`NotImplementedError`, `SystemStackError`, a custom
7
+ # `Exception` subclass) — so it fails its step and rolls back (008 R-16).
8
+ #
9
+ # It lets through only interruptions: exceptions raised INTO running code
10
+ # from outside it. A signal (incl. `Interrupt`, `Sidekiq::Shutdown`), an
11
+ # exit, running out of memory, and the one an enclosing `Timeout.timeout`
12
+ # raises — rescuing that would stop the caller's timeout from ever firing.
13
+ # Running rollback code during any of them is unsafe; the executor marks
14
+ # the run `aborted` for a manual undo instead.
15
+ module Rescuable
16
+ INTERRUPTIONS = [SignalException, SystemExit, NoMemoryError].freeze
17
+
18
+ def self.===(exception)
19
+ exception.is_a?(Exception) && !interruption?(exception)
20
+ end
21
+
22
+ def self.interruption?(exception)
23
+ INTERRUPTIONS.any? { |klass| exception.is_a?(klass) } ||
24
+ (defined?(::Timeout::ExitException) && exception.is_a?(::Timeout::ExitException))
25
+ end
26
+ end
27
+ end
28
+ end
@@ -3,15 +3,17 @@
3
3
  module RubyReactor
4
4
  class Executor
5
5
  class CompensationManager
6
- # Raised ONLY by the step's own coordination, before its body: a
7
- # coordination error raised from inside a body (a nested direct
6
+ # Raised ONLY before the step's body: by its own coordination, or while
7
+ # resolving its arguments (008 R-06).
8
+ # A coordination error raised from inside a body (a nested direct
8
9
  # `Step.run`) arrives as `StepCoordination::NestedCoordinationError`, and
9
10
  # a bare `Lock::AcquisitionError` etc. may come from a nested
10
11
  # `Reactor.run` — both mean the body ran, so neither is listed here.
11
12
  NEVER_STARTED_ERROR_CLASSES = [
12
13
  RubyReactor::Executor::StepCoordination::Contended,
13
14
  RubyReactor::Executor::StepCoordination::KeyError,
14
- RubyReactor::Executor::StepCoordination::DispatchRefused
15
+ RubyReactor::Executor::StepCoordination::DispatchRefused,
16
+ RubyReactor::Error::ArgumentResolutionError
15
17
  ].freeze
16
18
 
17
19
  def initialize(context)
@@ -65,15 +67,26 @@ module RubyReactor
65
67
  end
66
68
  end
67
69
 
70
+ # A unit that compensates itself outside the executor loop (StepWorker's
71
+ # `async_step`): the same coordination re-take, trace, middleware events
72
+ # and `rollback_failures` as a step compensated here.
73
+ def compensate(step_config, error, arguments)
74
+ compensate_step(step_config, error, arguments)
75
+ end
76
+
77
+ # Newest first. Each entry leaves the stack only once its undo returned,
78
+ # so an interruption mid-rollback leaves exactly the entries still to
79
+ # undo — the interrupted one included — for a manual undo (008 R-16).
68
80
  def rollback_completed_steps
69
- undo_stack.reverse_each do |step_info|
81
+ until undo_stack.empty?
82
+ step_info = undo_stack.last
70
83
  result = @context.with_step(step_info[:step].name) do
71
84
  undo_step(step_info[:step], step_info[:result], step_info[:arguments])
72
85
  end
86
+ undo_stack.pop
73
87
  @undo_trace << { type: :undo, step: step_info[:step].name, result: result,
74
88
  arguments: step_info[:arguments] }
75
89
  end
76
- undo_stack.clear
77
90
  end
78
91
 
79
92
  private
@@ -117,19 +130,18 @@ module RubyReactor
117
130
  ).around_rollback(&block)
118
131
  end
119
132
 
133
+ # Under `with_step`, as `rollback_completed_steps` runs each undo: a
134
+ # construct (compose, map) reads `context.current_step` to find its own
135
+ # state during either rollback moment.
120
136
  def compensate_step(step_config, error, arguments)
137
+ @context.with_step(step_config.name) { compensate_step_body(step_config, error, arguments) }
138
+ end
139
+
140
+ def compensate_step_body(step_config, error, arguments)
121
141
  middlewares.on(:start_compensation, step_config.name, error, arguments, @context)
122
142
  begin
123
143
  compensate_result = coordinated_rollback(step_config, arguments) do
124
- catch(StepSignals::TAG) do
125
- if step_config.compensate_block
126
- step_config.compensate_block.call(error, step_config.wrap_inputs(arguments), @context)
127
- elsif step_config.has_impl?
128
- step_config.impl.compensate(error, arguments, @context)
129
- else
130
- RubyReactor.Skipped() # Default: nothing defined, rollback continues
131
- end
132
- end
144
+ step_config.call_compensate(error, arguments, @context)
133
145
  end
134
146
 
135
147
  @context.append_execution_trace(
@@ -152,7 +164,7 @@ module RubyReactor
152
164
  end
153
165
 
154
166
  compensate_result
155
- rescue StandardError => e
167
+ rescue Error::Rescuable => e
156
168
  record_rollback_failure(step_config.name, :compensate, e)
157
169
  middlewares.on(:failed_compensation, step_config.name, e, @context)
158
170
  # A raise is a compensation failure like a returned Failure: the
@@ -162,19 +174,11 @@ module RubyReactor
162
174
  end
163
175
  end
164
176
 
165
- def undo_step(step_config, result, arguments) # rubocop:disable Metrics/MethodLength
177
+ def undo_step(step_config, result, arguments)
166
178
  middlewares.on(:start_undo, step_config.name, result, arguments, @context)
167
179
  begin
168
180
  undo_result = coordinated_rollback(step_config, arguments) do
169
- catch(StepSignals::TAG) do
170
- if step_config.undo_block
171
- step_config.undo_block.call(result.value, step_config.wrap_inputs(arguments), @context)
172
- elsif step_config.has_impl?
173
- step_config.impl.undo(result.value, arguments, @context)
174
- else
175
- RubyReactor.Skipped() # Default: nothing defined, rollback continues
176
- end
177
- end
181
+ step_config.call_undo(result.value, arguments, @context)
178
182
  end
179
183
 
180
184
  @context.append_execution_trace(
@@ -196,7 +200,7 @@ module RubyReactor
196
200
  end
197
201
 
198
202
  undo_result
199
- rescue StandardError => e
203
+ rescue Error::Rescuable => e
200
204
  record_rollback_failure(step_config.name, :undo, e)
201
205
  middlewares.on(:failed_undo, step_config.name, e, @context)
202
206
  # Log undo failure but don't halt the rollback process
@@ -62,12 +62,20 @@ module RubyReactor
62
62
  @compensation_manager.rollback_completed_steps
63
63
  build_validation_failure(error)
64
64
  when Error::Base
65
- # Other errors need rollback
65
+ # Other errors need rollback. A `CompensationError` names the step
66
+ # whose compensation failed.
66
67
  @compensation_manager.rollback_completed_steps
67
- RubyReactor.Failure("Execution error: #{error.message}", exception_class: error.class.name)
68
+ RubyReactor.Failure("Execution error: #{error.message}", exception_class: error.class.name,
69
+ step_name: error.step || @context.current_step,
70
+ reactor_name: @context.reactor_class&.name)
68
71
  else
69
- # Unknown errors - don't rollback as they may not be reactor-related
70
- RubyReactor.Failure("Execution failed: #{error.message}", exception_class: error.class.name)
72
+ # Any other StandardError after completed work (a checkpoint write,
73
+ # a hook) still leaves a partial saga: roll it back like any failure
74
+ # (Constitution II, 008 R-07) and name the step that was executing.
75
+ @compensation_manager.rollback_completed_steps
76
+ RubyReactor.Failure("Execution failed: #{error.message}", exception_class: error.class.name,
77
+ step_name: @context.current_step,
78
+ reactor_name: @context.reactor_class&.name)
71
79
  end
72
80
  end
73
81
 
@@ -108,9 +116,9 @@ module RubyReactor
108
116
  result
109
117
  end
110
118
 
111
- # A step returned `RubyReactor.Skipped(...)`. The reactor continues:
112
- # validate and record the value exactly like a Success, but do NOT push
113
- # to the undo stack (nothing happened, so there is nothing to undo).
119
+ # A step returned `RubyReactor.Skipped(...)`: the run continues exactly as
120
+ # for a Success (value, hand-off, period mark), but the step is NOT pushed
121
+ # for undo — it had nothing to do, so there is nothing to revert (008 R-19).
114
122
  def handle_skipped(step_config, result, resolved_arguments)
115
123
  validate_step_output(step_config, result.value, resolved_arguments)
116
124
  @step_results[step_config.name] = result
@@ -130,11 +138,7 @@ module RubyReactor
130
138
  def handle_success(step_config, result, resolved_arguments)
131
139
  validate_step_output(step_config, result.value, resolved_arguments)
132
140
  @step_results[step_config.name] = result
133
- # `async_step` / `async_reactor` dispatches are independent units of
134
- # work with their own compensation flows — the parent rolling back must
135
- # not "undo" a dispatch whose unit runs (and may still succeed)
136
- # elsewhere. They never enter the parent's undo stack.
137
- unless async_unit?(step_config)
141
+ if step_config.rollback_tracked?
138
142
  @compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
139
143
  result: result })
140
144
  end
@@ -142,10 +146,6 @@ module RubyReactor
142
146
  @dependency_graph.complete_step(step_config.name)
143
147
  end
144
148
 
145
- def async_unit?(step_config)
146
- step_config.respond_to?(:async_dispatch?) && step_config.async_dispatch?
147
- end
148
-
149
149
  # A composed child's Failure carries the child's own rollback failures;
150
150
  # fold them in BEFORE this level rolls back, so they come first.
151
151
  def adopt_rollback_failures(result)
@@ -254,6 +254,8 @@ module RubyReactor
254
254
  def resolve_exception_class(original_error, error)
255
255
  # A step's own contention is reported by its cause (Lock::AcquisitionError, ...).
256
256
  original_error = original_error.original if original_error.is_a?(StepCoordination::Contended)
257
+ # Argument failures report their cause's class (008 R-06).
258
+ return original_error.exception_class if original_error.is_a?(Error::ArgumentResolutionError)
257
259
  return original_error.class.name if original_error
258
260
 
259
261
  error.respond_to?(:exception_class) ? error.exception_class : nil
@@ -99,7 +99,7 @@ module RubyReactor
99
99
  key
100
100
  rescue KeyError
101
101
  raise
102
- rescue StandardError => e
102
+ rescue RubyReactor::Error::Rescuable => e
103
103
  raise KeyError.new(
104
104
  "#{step_name}: coordination key proc raised #{e.class}: #{e.message}",
105
105
  step: step_name, original_error: e
@@ -188,7 +188,7 @@ module RubyReactor
188
188
  # counter and marker must survive the park.
189
189
  rescue Contended, Error::ExecutionParked
190
190
  raise
191
- rescue StandardError
191
+ rescue Error::Rescuable
192
192
  clear_contention_state
193
193
  raise
194
194
  end
@@ -498,7 +498,7 @@ module RubyReactor
498
498
  rescue Error::ExecutionParked
499
499
  outcome = :parked
500
500
  raise
501
- rescue StandardError
501
+ rescue Error::Rescuable
502
502
  outcome = :failed
503
503
  raise
504
504
  ensure
@@ -513,8 +513,8 @@ module RubyReactor
513
513
  # step again and must keep its place: the stash survives, so the next
514
514
  # attempt re-reads the SAME nonce. The heartbeat is stopped across the
515
515
  # gap; `poison_pill_timeout` bounds it.
516
- # - :abandoned — an exit that is not a `StandardError` (`Sidekiq::Shutdown`,
517
- # `NoMemoryError`, ...). Not advanced: `Sidekiq::Shutdown` pushes the
516
+ # - :abandoned — an interruption (`Sidekiq::Shutdown`, `NoMemoryError`,
517
+ # ...; `Error::Rescuable`, 008 R-16). Not advanced: `Sidekiq::Shutdown` pushes the
518
518
  # job back to run again, which must keep this place, and `failed: true`
519
519
  # would poison successors for work that may yet complete. With the
520
520
  # heartbeat stopped, the poison pill releases the position within
@@ -644,7 +644,7 @@ module RubyReactor
644
644
  return skipped_for_period if storage_adapter.period_seen?(key)
645
645
 
646
646
  result = yield
647
- if plain_success?(result) && !chain_failed?(result)
647
+ if continuing_success?(result) && !chain_failed?(result)
648
648
  storage_adapter.period_mark(key, RubyReactor::Period.ttl_seconds(config[:every]))
649
649
  end
650
650
  result
@@ -654,8 +654,11 @@ module RubyReactor
654
654
  RubyReactor.Skipped(nil, reason: :period, step_name: step_name)
655
655
  end
656
656
 
657
- def plain_success?(result)
658
- result.is_a?(RubyReactor::Success) && !result.is_a?(RubyReactor::Halt) && !result.is_a?(RubyReactor::Skipped)
657
+ # A body's `Skipped` marks the bucket like a Success: it is only an
658
+ # instrumentation mark. The library's own skips (period, ordered lock)
659
+ # are returned by the gates around this one, never through it.
660
+ def continuing_success?(result)
661
+ result.is_a?(RubyReactor::Success) && !result.is_a?(RubyReactor::Halt)
659
662
  end
660
663
 
661
664
  def period_key(config)