ruby_reactor 0.8.3 → 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 (89) 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 +208 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +140 -102
  7. data/lib/ruby_reactor/dsl/async_macros.rb +30 -1
  8. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +10 -7
  9. data/lib/ruby_reactor/dsl/compose_builder.rb +10 -11
  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/reactor.rb +8 -11
  13. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  14. data/lib/ruby_reactor/dsl/step_builder.rb +131 -33
  15. data/lib/ruby_reactor/error/argument_resolution_error.rb +19 -0
  16. data/lib/ruby_reactor/error/rescuable.rb +28 -0
  17. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  18. data/lib/ruby_reactor/executor/compensation_manager.rb +30 -26
  19. data/lib/ruby_reactor/executor/result_handler.rb +21 -18
  20. data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
  21. data/lib/ruby_reactor/executor/step_coordination.rb +11 -8
  22. data/lib/ruby_reactor/executor/step_executor.rb +59 -49
  23. data/lib/ruby_reactor/executor.rb +38 -4
  24. data/lib/ruby_reactor/map/collector.rb +21 -11
  25. data/lib/ruby_reactor/map/dispatcher.rb +29 -3
  26. data/lib/ruby_reactor/map/element_executor.rb +9 -3
  27. data/lib/ruby_reactor/map/helpers.rb +32 -2
  28. data/lib/ruby_reactor/map/result_enumerator.rb +18 -12
  29. data/lib/ruby_reactor/reactor.rb +24 -0
  30. data/lib/ruby_reactor/rspec/matchers.rb +19 -3
  31. data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
  32. data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
  33. data/lib/ruby_reactor/step/compose_step.rb +14 -4
  34. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  35. data/lib/ruby_reactor/step/inputs.rb +57 -0
  36. data/lib/ruby_reactor/step/map_step.rb +140 -25
  37. data/lib/ruby_reactor/step.rb +20 -7
  38. data/lib/ruby_reactor/step_worker.rb +46 -22
  39. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  40. data/lib/ruby_reactor/storage/redis_adapter.rb +9 -0
  41. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +1 -1
  42. data/lib/ruby_reactor/version.rb +1 -1
  43. data/lib/ruby_reactor/web/api.rb +1 -1
  44. data/lib/ruby_reactor/web/public/assets/{index-CeZU-ESu.js → index-CQbgHtd0.js} +10 -10
  45. data/lib/ruby_reactor/web/public/index.html +1 -1
  46. data/lib/ruby_reactor/worker.rb +3 -1
  47. data/lib/ruby_reactor.rb +20 -7
  48. data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
  49. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  50. data/specs/006-step-retry-declarations/data-model.md +58 -0
  51. data/specs/006-step-retry-declarations/plan.md +187 -0
  52. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  53. data/specs/006-step-retry-declarations/research.md +194 -0
  54. data/specs/006-step-retry-declarations/spec.md +453 -0
  55. data/specs/006-step-retry-declarations/tasks.md +382 -0
  56. data/specs/007-execution-flow-analysis/analysis/README.md +147 -0
  57. data/specs/007-execution-flow-analysis/analysis/execution-order.md +359 -0
  58. data/specs/007-execution-flow-analysis/analysis/findings-and-options.md +502 -0
  59. data/specs/007-execution-flow-analysis/analysis/invariants.md +109 -0
  60. data/specs/007-execution-flow-analysis/checklists/requirements.md +39 -0
  61. data/specs/007-execution-flow-analysis/contracts/report-structure.md +71 -0
  62. data/specs/007-execution-flow-analysis/data-model.md +83 -0
  63. data/specs/007-execution-flow-analysis/evidence/harness.rb +229 -0
  64. data/specs/007-execution-flow-analysis/evidence/output.txt +333 -0
  65. data/specs/007-execution-flow-analysis/evidence/probes/01_plain.rb +122 -0
  66. data/specs/007-execution-flow-analysis/evidence/probes/02_compose.rb +182 -0
  67. data/specs/007-execution-flow-analysis/evidence/probes/03_map.rb +232 -0
  68. data/specs/007-execution-flow-analysis/evidence/probes/04_async.rb +132 -0
  69. data/specs/007-execution-flow-analysis/evidence/probes/05_background.rb +58 -0
  70. data/specs/007-execution-flow-analysis/evidence/probes/06_coordination.rb +158 -0
  71. data/specs/007-execution-flow-analysis/evidence/probes/07_interrupts_manual.rb +185 -0
  72. data/specs/007-execution-flow-analysis/evidence/run.rb +15 -0
  73. data/specs/007-execution-flow-analysis/plan.md +127 -0
  74. data/specs/007-execution-flow-analysis/quickstart.md +51 -0
  75. data/specs/007-execution-flow-analysis/research.md +202 -0
  76. data/specs/007-execution-flow-analysis/spec.md +270 -0
  77. data/specs/007-execution-flow-analysis/tasks.md +257 -0
  78. data/specs/008-rollback-reliability/checklists/requirements.md +43 -0
  79. data/specs/008-rollback-reliability/contracts/api-surface.md +126 -0
  80. data/specs/008-rollback-reliability/contracts/rollback-semantics.md +76 -0
  81. data/specs/008-rollback-reliability/data-model.md +139 -0
  82. data/specs/008-rollback-reliability/plan.md +233 -0
  83. data/specs/008-rollback-reliability/quickstart.md +105 -0
  84. data/specs/008-rollback-reliability/research.md +653 -0
  85. data/specs/008-rollback-reliability/spec.md +561 -0
  86. data/specs/008-rollback-reliability/tasks.md +1110 -0
  87. data/specs/future_improvements.md +48 -0
  88. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  89. metadata +47 -2
@@ -18,15 +18,21 @@ module RubyReactor
18
18
  @child_reactor_class = child_reactor_class
19
19
  @reactor = reactor
20
20
  @argument_mappings = {}
21
- @retry_config = {}
22
21
  end
23
22
 
24
23
  def argument(child_input_name, source)
25
24
  @argument_mappings[child_input_name] = source
26
25
  end
27
26
 
28
- def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
29
- @retry_config = { max_attempts: max_attempts, backoff: backoff, base_delay: base_delay }
27
+ # A parent never retries a nested reactor as a whole (008 R-14): the
28
+ # child owns its steps' retries. Kept as a stub to name the replacement.
29
+ def retries(*)
30
+ raise RubyReactor::Error::DeprecatedDslError.new(
31
+ "`retries` on an `async_reactor` has been removed: a parent never retries a nested " \
32
+ "reactor as a whole. Declare `retries` on :#{@name}'s child reactor's own steps " \
33
+ "(`retries max_attempts: 3` in the step block or the step class); the child retries them itself.",
34
+ step: @name
35
+ )
30
36
  end
31
37
 
32
38
  def build
@@ -44,12 +50,9 @@ module RubyReactor
44
50
  # that reads `result(:name)` and decides to fail.
45
51
  compensate_block: nil,
46
52
  undo_block: nil,
47
- conditions: [],
48
- guards: [],
49
53
  dependencies: dependencies_from_mappings,
50
54
  args_validator: nil,
51
- output_validator: nil,
52
- retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
55
+ output_validator: nil
53
56
  )
54
57
  end
55
58
 
@@ -20,7 +20,6 @@ module RubyReactor
20
20
  end
21
21
  @reactor = reactor
22
22
  @argument_mappings = {}
23
- @retry_config = {}
24
23
  end
25
24
 
26
25
  def argument(composed_input_name, source)
@@ -44,12 +43,15 @@ module RubyReactor
44
43
  )
45
44
  end
46
45
 
47
- def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
48
- @retry_config = {
49
- max_attempts: max_attempts,
50
- backoff: backoff,
51
- base_delay: base_delay
52
- }
46
+ # A parent never retries a nested reactor as a whole (008 R-14): the
47
+ # child owns its steps' retries. Kept as a stub to name the replacement.
48
+ def retries(*)
49
+ raise RubyReactor::Error::DeprecatedDslError.new(
50
+ "`retries` on a `compose` has been removed: a parent never retries a nested " \
51
+ "reactor as a whole. Declare `retries` on :#{@name}'s child reactor's own steps " \
52
+ "(`retries max_attempts: 3` in the step block or the step class); the child retries them itself.",
53
+ step: @name
54
+ )
53
55
  end
54
56
 
55
57
  def build
@@ -66,12 +68,9 @@ module RubyReactor
66
68
  run_block: nil,
67
69
  compensate_block: nil,
68
70
  undo_block: nil,
69
- conditions: [],
70
- guards: [],
71
71
  dependencies: dependencies,
72
72
  args_validator: nil,
73
- output_validator: nil,
74
- retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
73
+ output_validator: nil
75
74
  }
76
75
 
77
76
  RubyReactor::Dsl::StepConfig.new(step_config)
@@ -73,9 +73,7 @@ module RubyReactor
73
73
  validation_schema: @validation_schema,
74
74
  max_attempts: @max_attempts,
75
75
  resume_mode: @resume_mode,
76
- dependencies: @dependencies,
77
- conditions: @conditions,
78
- guards: @guards
76
+ dependencies: @dependencies
79
77
  }
80
78
 
81
79
  RubyReactor::Dsl::InterruptStepConfig.new(step_config)
@@ -129,8 +129,6 @@ module RubyReactor
129
129
  run_block: nil,
130
130
  compensate_block: nil,
131
131
  undo_block: nil,
132
- conditions: [],
133
- guards: [],
134
132
  dependencies: dependencies.uniq,
135
133
  args_validator: nil,
136
134
  output_validator: nil
@@ -11,7 +11,6 @@ module RubyReactor
11
11
  base.instance_variable_set(:@middlewares, [])
12
12
  base.instance_variable_set(:@input_validations, {})
13
13
  base.instance_variable_set(:@background_handoff, nil)
14
- base.instance_variable_set(:@retry_defaults, { max_attempts: 3, backoff: :exponential, base_delay: 1 })
15
14
  end
16
15
 
17
16
  module ClassMethods
@@ -55,16 +54,14 @@ module RubyReactor
55
54
  "inputs inside the worker."
56
55
  end
57
56
 
58
- def retry_defaults(**kwargs)
59
- if kwargs.empty?
60
- @retry_defaults ||= { max_attempts: 1, backoff: :exponential, base_delay: 1 }
61
- else
62
- @retry_defaults = {
63
- max_attempts: kwargs[:max_attempts] || 1,
64
- backoff: kwargs[:backoff] || :exponential,
65
- base_delay: kwargs[:base_delay] || 1
66
- }
67
- end
57
+ # Reactor-wide retry defaults are gone: the builders snapshotted them at
58
+ # `step` time, so they silently applied only to steps declared AFTER
59
+ # the `retry_defaults` line. A step's retry policy now lives on the step.
60
+ def retry_defaults(*, **)
61
+ raise RubyReactor::Error::DeprecatedDslError,
62
+ "`retry_defaults` has been removed from #{name || "this reactor"}: reactor-wide defaults " \
63
+ "silently applied only to steps declared after them. Declare `retries` on each step class " \
64
+ "(or step block) that should retry; a step with no `retries` runs once."
68
65
  end
69
66
 
70
67
  # rubocop:disable Metrics/ParameterLists
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Dsl
5
+ # The one `retries` vocabulary. A step class EXTENDS it (class-level DSL);
6
+ # the step, compose and async_reactor builders INCLUDE it, so every form
7
+ # shares the same defaults and validation. Modeled on
8
+ # `Lockable::ClassMethods`: `inherited` copies the declaration down, so a
9
+ # subclass redeclaring leaves its parent and siblings untouched.
10
+ module Retryable
11
+ BACKOFF_STRATEGIES = %i[exponential linear fixed].freeze
12
+
13
+ # The declared policy, or nil when nothing was declared.
14
+ attr_reader :retry_config
15
+
16
+ def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
17
+ unless max_attempts.is_a?(Integer) && max_attempts >= 1
18
+ invalid_retries!(:max_attempts, "an Integer >= 1", max_attempts)
19
+ end
20
+ invalid_retries!(:backoff, "one of #{BACKOFF_STRATEGIES.inspect}", backoff) unless
21
+ BACKOFF_STRATEGIES.include?(backoff)
22
+ invalid_retries!(:base_delay, "a Numeric >= 0", base_delay) unless
23
+ base_delay.is_a?(Numeric) && base_delay >= 0
24
+
25
+ @retry_config = { max_attempts: max_attempts, backoff: backoff, base_delay: base_delay }
26
+ end
27
+
28
+ def inherited(subclass)
29
+ super
30
+ subclass.instance_variable_set(:@retry_config, @retry_config) if @retry_config
31
+ end
32
+
33
+ private
34
+
35
+ def invalid_retries!(option, expected, value)
36
+ raise ArgumentError, "#{retry_owner_label}: retries #{option} must be #{expected} (got #{value.inspect})"
37
+ end
38
+
39
+ # A step class's name, or a builder's step name.
40
+ def retry_owner_label
41
+ name&.to_s || inspect
42
+ end
43
+ end
44
+ end
45
+ end
@@ -6,14 +6,15 @@ module RubyReactor
6
6
  include RubyReactor::Dsl::TemplateHelpers
7
7
  include RubyReactor::Dsl::ValidationHelpers
8
8
  include RubyReactor::Dsl::Lockable::ClassMethods
9
+ include RubyReactor::Dsl::Retryable
9
10
 
10
11
  COORDINATION_MACROS = {
11
12
  lock_config: "with_lock", semaphore_config: "with_semaphore", rate_limit_config: "with_rate_limit",
12
13
  period_config: "with_period", ordered_lock_config: "with_ordered_lock"
13
14
  }.freeze
14
15
 
15
- attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
16
- :dependencies, :args_validator, :output_validator, :retry_config
16
+ attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
17
+ :dependencies, :args_validator, :output_validator
17
18
 
18
19
  def initialize(name, impl = nil, reactor = nil)
19
20
  @name = name
@@ -23,14 +24,12 @@ module RubyReactor
23
24
  @run_block = nil
24
25
  @compensate_block = nil
25
26
  @undo_block = nil
26
- @conditions = []
27
- @guards = []
28
27
  @dependencies = []
29
28
  @arg_validations = []
30
29
  @validate_args_input = nil
31
30
  @args_validator = nil
32
31
  @output_validator = nil
33
- @retry_config = {}
32
+ @retry_config = nil
34
33
  @inline_contract = nil
35
34
  @rule_sites = []
36
35
  end
@@ -77,12 +76,15 @@ module RubyReactor
77
76
  @undo_block = block
78
77
  end
79
78
 
80
- def where(&predicate)
81
- @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)
82
84
  end
83
85
 
84
- def guard(&guard_fn)
85
- @guards << guard_fn
86
+ def guard(*)
87
+ raise_removed_condition!(:guard)
86
88
  end
87
89
 
88
90
  def wait_for(*step_names)
@@ -130,20 +132,13 @@ module RubyReactor
130
132
  )
131
133
  end
132
134
 
133
- def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
134
- @retry_config = {
135
- max_attempts: max_attempts,
136
- backoff: backoff,
137
- base_delay: base_delay
138
- }
139
- end
140
-
141
135
  # `async_dispatch` marks a step whose work is dispatched as an independent
142
136
  # unit rather than run inline — `:step` for `async_step`, `:reactor` for
143
137
  # `async_reactor`. Nil for an ordinary step.
144
138
  def build(async_dispatch: nil)
145
139
  check_contract_conflicts!
146
140
  check_coordination_conflicts!
141
+ check_retry_conflict!
147
142
  warn_deprecated_rules
148
143
 
149
144
  step_config = {
@@ -154,13 +149,11 @@ module RubyReactor
154
149
  run_block: @run_block,
155
150
  compensate_block: @compensate_block,
156
151
  undo_block: @undo_block,
157
- conditions: @conditions,
158
- guards: @guards,
159
152
  dependencies: @dependencies,
160
153
  args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
161
154
  output_validator: @output_validator,
162
155
  inline_contract: @inline_contract,
163
- retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config,
156
+ retry_config: @retry_config,
164
157
  lock_config: @lock_config,
165
158
  semaphore_config: @semaphore_config,
166
159
  rate_limit_config: @rate_limit_config,
@@ -173,6 +166,15 @@ module RubyReactor
173
166
 
174
167
  private
175
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
+
176
178
  # The same primitive declared BOTH inline and on the step class is
177
179
  # ambiguous — two keys for one slot of the fixed acquisition order, and
178
180
  # `StepConfig`'s readers would silently let the inline one win. Refuse it
@@ -190,6 +192,16 @@ module RubyReactor
190
192
  end
191
193
  end
192
194
 
195
+ # Same rule as the coordination macros: one retry policy per step.
196
+ def check_retry_conflict!
197
+ return unless @retry_config && @impl.respond_to?(:retry_config) && @impl.retry_config
198
+
199
+ raise Error::ValidationError,
200
+ "#{reactor_label} step :#{@name} declares `retries` inline, but #{@impl} declares it too. " \
201
+ "Keep ONE: drop the inline declaration to use #{@impl}'s, or remove it from #{@impl}. " \
202
+ "To vary the policy per workflow, subclass #{@impl} and declare `retries` there."
203
+ end
204
+
193
205
  # A step that owns its input contract takes wiring only from the
194
206
  # reactor: rules here would be a second, overlapping rule set.
195
207
  def check_contract_conflicts!
@@ -233,11 +245,15 @@ module RubyReactor
233
245
  end
234
246
 
235
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)
236
253
  location = "#{site.path}:#{site.lineno}"
237
254
  return unless StepBuilder.deprecation_sites.add?(location)
238
255
 
239
- warn "[RubyReactor] DEPRECATION: #{location} #{reactor_label} #{message} " \
240
- "Removal no earlier than the next MAJOR."
256
+ warn ["[RubyReactor]", prefix, location, reactor_label, message].compact.join(" ")
241
257
  end
242
258
 
243
259
  def owned_contract
@@ -250,8 +266,10 @@ module RubyReactor
250
266
  end
251
267
 
252
268
  class StepConfig
253
- attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
254
- :dependencies, :args_validator, :output_validator, :retry_config, :async_dispatch,
269
+ NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }.freeze
270
+
271
+ attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block,
272
+ :dependencies, :args_validator, :output_validator, :async_dispatch,
255
273
  :inline_contract
256
274
 
257
275
  def initialize(config)
@@ -262,13 +280,11 @@ module RubyReactor
262
280
  @run_block = config[:run_block]
263
281
  @compensate_block = config[:compensate_block]
264
282
  @undo_block = config[:undo_block]
265
- @conditions = config[:conditions] || []
266
- @guards = config[:guards] || []
267
283
  @dependencies = config[:dependencies] || []
268
284
  @args_validator = config[:args_validator]
269
285
  @output_validator = config[:output_validator]
270
286
  @inline_contract = config[:inline_contract]
271
- @retry_config = { max_attempts: 1 }.merge(config[:retry_config] || {})
287
+ @retry_config = config[:retry_config]
272
288
  @lock_config = config[:lock_config]
273
289
  @semaphore_config = config[:semaphore_config]
274
290
  @rate_limit_config = config[:rate_limit_config]
@@ -287,6 +303,21 @@ module RubyReactor
287
303
  @lock_config || (impl.lock_config if impl.respond_to?(:lock_config))
288
304
  end
289
305
 
306
+ # A step's EFFECTIVE retry policy, resolved like the coordination
307
+ # readers: its own (step block) declaration, else the class step's,
308
+ # else a single attempt. Never nil. The reactor is never consulted.
309
+ def retry_config
310
+ @retry_config || (impl.retry_config if impl.respond_to?(:retry_config)) || NO_RETRIES
311
+ end
312
+
313
+ # Where `retry_config` came from: :step_block, :step_class or :none.
314
+ def retry_source
315
+ return :step_block if @retry_config
316
+ return :step_class if impl.respond_to?(:retry_config) && impl.retry_config
317
+
318
+ :none
319
+ end
320
+
290
321
  def semaphore_config
291
322
  @semaphore_config || (impl.semaphore_config if impl.respond_to?(:semaphore_config))
292
323
  end
@@ -337,13 +368,58 @@ module RubyReactor
337
368
  input_contract ? input_contract.enforce!(args) : args
338
369
  end
339
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
+
340
416
  # The step's work as a reactor runs it. Coordination is NOT taken here:
341
417
  # the caller (StepExecutor / StepWorker) takes this config's effective
342
418
  # declarations — inline and class alike — in one fixed order around it.
343
419
  def call_body(arguments, context)
344
420
  catch(StepSignals::TAG) do
345
421
  if has_run_block?
346
- run_block.call(arguments, context)
422
+ run_block.call(wrap_inputs(arguments), context)
347
423
  elsif impl.respond_to?(:run_without_coordination)
348
424
  impl.run_without_coordination(arguments, context)
349
425
  else
@@ -360,6 +436,20 @@ module RubyReactor
360
436
  !@async_dispatch.nil?
361
437
  end
362
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
+
363
453
  def has_impl?
364
454
  !@impl.nil?
365
455
  end
@@ -373,18 +463,26 @@ module RubyReactor
373
463
  !@run_block.nil?
374
464
  end
375
465
 
376
- def retryable?
377
- (retry_config[:max_attempts] || 0) > 1
466
+ # What an inline `run`/`undo`/`compensate` block receives as its inputs.
467
+ def wrap_inputs(arguments)
468
+ RubyReactor::Step::Inputs.new(arguments, contract: input_contract, owner: "step :#{name}")
378
469
  end
379
470
 
380
- def should_run?(context)
381
- @conditions.all? { |condition| condition.call(context) } &&
382
- @guards.all? { |guard| guard.call(context) }
471
+ def retryable?
472
+ (retry_config[:max_attempts] || 0) > 1
383
473
  end
384
474
 
385
475
  def interrupt?
386
476
  false
387
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
388
486
  end
389
487
  end
390
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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Error
5
+ # A step read an input it can't read (a typo, or a name it never
6
+ # declared). It fails the same way on every attempt, so it never retries.
7
+ class UndeclaredInputError < NoMethodError
8
+ def retryable?
9
+ false
10
+ end
11
+ end
12
+ end
13
+ 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, 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, 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