ruby_reactor 0.7.0 → 0.8.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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
  3. data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
  4. data/.release-please-manifest.json +1 -1
  5. data/.specify/feature.json +1 -1
  6. data/.specify/memory/constitution.md +92 -15
  7. data/.specify/templates/plan-template.md +4 -0
  8. data/.specify/templates/tasks-template.md +8 -1
  9. data/CHANGELOG.md +151 -0
  10. data/CLAUDE.md +2 -2
  11. data/README.md +149 -40
  12. data/lib/ruby_reactor/context.rb +9 -2
  13. data/lib/ruby_reactor/context_serializer.rb +13 -0
  14. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  15. data/lib/ruby_reactor/dsl/lockable.rb +2 -2
  16. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  17. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  18. data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
  19. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  20. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  21. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  22. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  23. data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
  24. data/lib/ruby_reactor/executor/result_handler.rb +44 -13
  25. data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
  26. data/lib/ruby_reactor/executor/step_executor.rb +27 -14
  27. data/lib/ruby_reactor/executor.rb +20 -15
  28. data/lib/ruby_reactor/map/element_executor.rb +7 -1
  29. data/lib/ruby_reactor/map/helpers.rb +9 -7
  30. data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
  31. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  32. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  33. data/lib/ruby_reactor/open_telemetry.rb +7 -4
  34. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  35. data/lib/ruby_reactor/reactor.rb +9 -12
  36. data/lib/ruby_reactor/rspec/matchers.rb +64 -17
  37. data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
  38. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  39. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  40. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  41. data/lib/ruby_reactor/step/map_step.rb +178 -215
  42. data/lib/ruby_reactor/step.rb +119 -18
  43. data/lib/ruby_reactor/step_signals.rb +37 -0
  44. data/lib/ruby_reactor/step_worker.rb +25 -10
  45. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  46. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
  47. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  48. data/lib/ruby_reactor/template/result.rb +9 -2
  49. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  50. data/lib/ruby_reactor/version.rb +1 -1
  51. data/lib/ruby_reactor/web/api.rb +23 -6
  52. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  53. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  54. data/lib/ruby_reactor/web/public/index.html +2 -2
  55. data/lib/ruby_reactor.rb +61 -9
  56. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  57. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  58. data/specs/002-step-input-contracts/data-model.md +115 -0
  59. data/specs/002-step-input-contracts/plan.md +165 -0
  60. data/specs/002-step-input-contracts/quickstart.md +170 -0
  61. data/specs/002-step-input-contracts/research.md +233 -0
  62. data/specs/002-step-input-contracts/spec.md +359 -0
  63. data/specs/002-step-input-contracts/tasks.md +367 -0
  64. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  65. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  66. data/specs/004-inheritable-step-class/data-model.md +116 -0
  67. data/specs/004-inheritable-step-class/plan.md +174 -0
  68. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  69. data/specs/004-inheritable-step-class/research.md +308 -0
  70. data/specs/004-inheritable-step-class/spec.md +316 -0
  71. data/specs/004-inheritable-step-class/tasks.md +258 -0
  72. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  73. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  74. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  75. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  76. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  77. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  78. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  79. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  80. data/specs/possible_feature.md +22 -0
  81. metadata +34 -11
  82. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
  83. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
  84. data/specs/001-background-async-steps/checklists/requirements.md +0 -39
  85. data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
  86. data/specs/001-background-async-steps/data-model.md +0 -117
  87. data/specs/001-background-async-steps/plan.md +0 -168
  88. data/specs/001-background-async-steps/quickstart.md +0 -102
  89. data/specs/001-background-async-steps/research.md +0 -150
  90. data/specs/001-background-async-steps/spec.md +0 -146
  91. data/specs/001-background-async-steps/tasks.md +0 -271
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- module Step
4
+ class Step
5
5
  # The dispatching half of `async_reactor`: everything that happens in the
6
6
  # PARENT's process. The child then runs as an ordinary independently
7
7
  # dispatched reactor execution — no new enqueue primitive, no new storage
@@ -14,193 +14,190 @@ module RubyReactor
14
14
  # validates, so skipping here starts a child on garbage), assign the
15
15
  # ordered-lock nonce at ENQUEUE time (so ordering matches caller order), and
16
16
  # persist before enqueueing (F2).
17
- class AsyncReactorStep
18
- include RubyReactor::Step
17
+ class AsyncReactorStep < RubyReactor::Step
18
+ def run
19
+ child_class = inputs[:async_reactor_class]
20
+ child_inputs = build_child_inputs(inputs[:argument_mappings] || {})
19
21
 
20
- class << self
21
- def run(arguments, context)
22
- child_class = arguments[:async_reactor_class]
23
- child_inputs = build_child_inputs(arguments[:argument_mappings] || {}, context)
22
+ # A dispatch-time failure fails the DISPATCHING step, i.e. normal saga
23
+ # handling in the parent. That is deliberately outside the
24
+ # no-auto-compensation rule, which governs the child's own execution.
25
+ validation = validate_child_inputs(child_class, child_inputs)
26
+ return validation if validation
24
27
 
25
- # A dispatch-time failure fails the DISPATCHING step, i.e. normal saga
26
- # handling in the parent. That is deliberately outside the
27
- # no-auto-compensation rule, which governs the child's own execution.
28
- validation = validate_child_inputs(child_class, child_inputs)
29
- return validation if validation
28
+ deadlock = detect_lock_deadlock(child_class, child_inputs)
29
+ return deadlock if deadlock
30
30
 
31
- deadlock = detect_lock_deadlock(child_class, child_inputs, context)
32
- return deadlock if deadlock
31
+ return run_inline(child_class, child_inputs) if run_inline?
33
32
 
34
- return run_inline(child_class, child_inputs, context) if run_inline?(context)
33
+ dispatch(child_class, child_inputs)
34
+ end
35
35
 
36
- dispatch(child_class, child_inputs, context)
37
- end
36
+ private
38
37
 
39
- private
38
+ def build_child_inputs(mappings)
39
+ mappings.transform_values { |source| source.resolve(context) }
40
+ end
40
41
 
41
- def build_child_inputs(mappings, context)
42
- mappings.transform_values { |source| source.resolve(context) }
43
- end
42
+ def validate_child_inputs(child_class, child_inputs)
43
+ return nil unless child_class.respond_to?(:validate_inputs)
44
44
 
45
- def validate_child_inputs(child_class, child_inputs)
46
- return nil unless child_class.respond_to?(:validate_inputs)
45
+ result = child_class.validate_inputs(child_inputs)
46
+ return nil unless result.failure?
47
47
 
48
- result = child_class.validate_inputs(child_inputs)
49
- return nil unless result.failure?
48
+ RubyReactor.Failure(
49
+ "async_reactor child #{child_class.name} rejected its inputs: #{result.error.message}",
50
+ validation_errors: (result.error.field_errors if result.error.respond_to?(:field_errors))
51
+ )
52
+ end
50
53
 
51
- RubyReactor.Failure(
52
- "async_reactor child #{child_class.name} rejected its inputs: #{result.error.message}",
53
- validation_errors: (result.error.field_errors if result.error.respond_to?(:field_errors))
54
- )
55
- end
54
+ # Lock ownership is NOT shared across the async boundary: parent
55
+ # and child run concurrently, so giving the child the parent's owner
56
+ # would put both inside the critical section at once — mutual exclusion
57
+ # silently broken, which is worse than a stall. Instead the one
58
+ # GUARANTEED deadlock (the parent holds a key its child will wait on,
59
+ # while the parent may go on to wait for that child) is caught here,
60
+ # loudly, at dispatch. Ordinary cross-execution contention on the same
61
+ # key is unaffected and still snoozes normally.
62
+ def detect_lock_deadlock(child_class, child_inputs)
63
+ held = held_lock_keys
64
+ return nil if held.empty?
65
+
66
+ collision = child_lock_keys(child_class, child_inputs).find { |key| held.include?(key) }
67
+ return nil unless collision
68
+
69
+ RubyReactor.Failure(deadlock_message(collision, child_class))
70
+ end
56
71
 
57
- # Lock ownership is NOT shared across the async boundary: parent
58
- # and child run concurrently, so giving the child the parent's owner
59
- # would put both inside the critical section at once — mutual exclusion
60
- # silently broken, which is worse than a stall. Instead the one
61
- # GUARANTEED deadlock (the parent holds a key its child will wait on,
62
- # while the parent may go on to wait for that child) is caught here,
63
- # loudly, at dispatch. Ordinary cross-execution contention on the same
64
- # key is unaffected and still snoozes normally.
65
- def detect_lock_deadlock(child_class, child_inputs, context)
66
- held = held_lock_keys(context)
67
- return nil if held.empty?
68
-
69
- collision = child_lock_keys(child_class, child_inputs).find { |key| held.include?(key) }
70
- return nil unless collision
71
-
72
- RubyReactor.Failure(deadlock_message(collision, child_class, context))
72
+ def child_lock_keys(child_class, child_inputs)
73
+ keys = []
74
+ if child_class.respond_to?(:lock_config) && child_class.lock_config
75
+ keys << child_class.lock_config[:key_proc].call(child_inputs)
73
76
  end
74
77
 
75
- def child_lock_keys(child_class, child_inputs)
76
- keys = []
77
- if child_class.respond_to?(:lock_config) && child_class.lock_config
78
- keys << child_class.lock_config[:key_proc].call(child_inputs)
79
- end
80
-
81
- # A semaphore with a single slot has the identical circular-wait
82
- # shape. Higher limits are contention, not guaranteed deadlock.
83
- semaphore = child_class.respond_to?(:semaphore_config) ? child_class.semaphore_config : nil
84
- keys << semaphore[:key_proc].call(child_inputs) if semaphore && semaphore[:limit] == 1
78
+ # A semaphore with a single slot has the identical circular-wait
79
+ # shape. Higher limits are contention, not guaranteed deadlock.
80
+ semaphore = child_class.respond_to?(:semaphore_config) ? child_class.semaphore_config : nil
81
+ keys << semaphore[:key_proc].call(child_inputs) if semaphore && semaphore[:limit] == 1
85
82
 
86
- keys.compact
87
- end
83
+ keys.compact
84
+ end
88
85
 
89
- def held_lock_keys(context)
90
- root = context.root_context || context
91
- Array(root.private_data[:held_lock_keys] || root.private_data["held_lock_keys"])
92
- end
86
+ def held_lock_keys
87
+ root = context.root_context || context
88
+ Array(root.private_data[:held_lock_keys] || root.private_data["held_lock_keys"])
89
+ end
93
90
 
94
- def deadlock_message(key, child_class, context)
95
- parent = context.reactor_class&.name || "the dispatching reactor"
96
- <<~MSG.strip
97
- async_reactor dispatch of #{child_class.name || "<anonymous>"} would deadlock: it declares the \
98
- lock key '#{key}', which #{parent} currently holds and will not release until it finishes.
99
- The child would snooze forever, and if #{parent} later reads this child's result it would wait \
100
- for work that can never start. Lock ownership is never shared across the async boundary — the \
101
- two run concurrently, so sharing it would break mutual exclusion outright.
102
- Fix, in order of preference:
103
- 1. Use `compose` instead of `async_reactor` if the child belongs inside #{parent}'s critical \
104
- section and its result is needed — waiting for it means the work is sequential anyway.
105
- 2. Narrow the lock keys, if parent and child actually protect different resources.
106
- 3. Restructure so the locked reactor never reads the child's result — fire-and-forget, and \
107
- verify in the child itself or in a successor reactor outside the lock window.
108
- MSG
109
- end
91
+ def deadlock_message(key, child_class)
92
+ parent = context.reactor_class&.name || "the dispatching reactor"
93
+ <<~MSG.strip
94
+ async_reactor dispatch of #{child_class.name || "<anonymous>"} would deadlock: it declares the \
95
+ lock key '#{key}', which #{parent} currently holds and will not release until it finishes.
96
+ The child would snooze forever, and if #{parent} later reads this child's result it would wait \
97
+ for work that can never start. Lock ownership is never shared across the async boundary — the \
98
+ two run concurrently, so sharing it would break mutual exclusion outright.
99
+ Fix, in order of preference:
100
+ 1. Use `compose` instead of `async_reactor` if the child belongs inside #{parent}'s critical \
101
+ section and its result is needed — waiting for it means the work is sequential anyway.
102
+ 2. Narrow the lock keys, if parent and child actually protect different resources.
103
+ 3. Restructure so the locked reactor never reads the child's result — fire-and-forget, and \
104
+ verify in the child itself or in a successor reactor outside the lock window.
105
+ MSG
106
+ end
110
107
 
111
- # `RSpec::TestSubject`'s `async: false` clears the dispatch marker to run
112
- # the whole reactor in one process.
113
- def run_inline?(context)
114
- config = context.reactor_class&.steps&.[](context.current_step)
115
- config.respond_to?(:async_dispatch?) && !config.async_dispatch?
116
- end
108
+ # `RSpec::TestSubject`'s `async: false` clears the dispatch marker to run
109
+ # the whole reactor in one process.
110
+ def run_inline?
111
+ config = context.reactor_class&.steps&.[](context.current_step)
112
+ config.respond_to?(:async_dispatch?) && !config.async_dispatch?
113
+ end
117
114
 
118
- def run_inline(child_class, child_inputs, context)
119
- result = child_class.run(child_inputs)
120
- store_reference(context, execution_id: (result.execution_id if result.respond_to?(:execution_id)),
121
- child_class: child_class)
122
- # Success(nil) for the same reason dispatch returns it: `result(:name)`
123
- # must route through the reference (which now points at an already
124
- # terminal child) rather than through a recorded value.
125
- RubyReactor.Success(nil)
126
- end
115
+ def run_inline(child_class, child_inputs)
116
+ result = child_class.run(child_inputs)
117
+ store_reference(
118
+ execution_id: (result.execution_id if result.respond_to?(:execution_id)), child_class: child_class
119
+ )
120
+ # Success(nil) for the same reason dispatch returns it: `result(:name)`
121
+ # must route through the reference (which now points at an already
122
+ # terminal child) rather than through a recorded value.
123
+ RubyReactor.Success(nil)
124
+ end
127
125
 
128
- def dispatch(child_class, child_inputs, context)
129
- child_context = build_child_context(child_class, child_inputs, context)
130
- assign_ordered_lock_nonce!(child_class, child_context)
131
-
132
- # Persist BEFORE enqueue (F2) — the payload is identity-only.
133
- child_context.status = :running
134
- # Marks the child as owned by a job of its own, which is what makes it
135
- # sweepable: a compose child carries the same parent_context_id but runs
136
- # inline in the parent, so re-enqueueing one would double-run it.
137
- child_context.private_data[:async_dispatched] = true
138
- save(child_context, child_class)
139
-
140
- # The reference is written synchronously, by the process that is about
141
- # to keep running other steps, so there is no cross-process race on it
142
- # (unlike the child's eventual result, which the child itself writes).
143
- store_reference(context, execution_id: child_context.context_id, child_class: child_class)
144
- log_dispatch(context, child_class, child_context)
145
-
146
- RubyReactor.configuration.async_router.perform_async(
147
- child_context.context_id, RubyReactor.reactor_storage_name(child_class)
148
- )
149
-
150
- RubyReactor.Success(nil)
151
- end
126
+ def dispatch(child_class, child_inputs)
127
+ child_context = build_child_context(child_class, child_inputs)
128
+ assign_ordered_lock_nonce!(child_class, child_context)
129
+
130
+ # Persist BEFORE enqueue (F2) — the payload is identity-only.
131
+ child_context.status = :running
132
+ # Marks the child as owned by a job of its own, which is what makes it
133
+ # sweepable: a compose child carries the same parent_context_id but runs
134
+ # inline in the parent, so re-enqueueing one would double-run it.
135
+ child_context.private_data[:async_dispatched] = true
136
+ save(child_context, child_class)
137
+
138
+ # The reference is written synchronously, by the process that is about
139
+ # to keep running other steps, so there is no cross-process race on it
140
+ # (unlike the child's eventual result, which the child itself writes).
141
+ store_reference(execution_id: child_context.context_id, child_class: child_class)
142
+ log_dispatch(child_class, child_context)
143
+
144
+ RubyReactor.configuration.async_router.perform_async(
145
+ child_context.context_id, RubyReactor.reactor_storage_name(child_class)
146
+ )
147
+
148
+ RubyReactor.Success(nil)
149
+ end
152
150
 
153
- def build_child_context(child_class, child_inputs, context)
154
- child_context = RubyReactor::Context.new(child_inputs, child_class)
155
- # Linked for traceability only — the child is NOT nested inside the
156
- # parent's context tree, because it must survive the parent finishing.
157
- child_context.parent_context_id = context.context_id
158
- child_context
159
- end
151
+ def build_child_context(child_class, child_inputs)
152
+ child_context = RubyReactor::Context.new(child_inputs, child_class)
153
+ # Linked for traceability only — the child is NOT nested inside the
154
+ # parent's context tree, because it must survive the parent finishing.
155
+ child_context.parent_context_id = context.context_id
156
+ child_context
157
+ end
160
158
 
161
- def assign_ordered_lock_nonce!(child_class, child_context)
162
- return unless child_class.respond_to?(:ordered_lock_config) && child_class.ordered_lock_config
159
+ def assign_ordered_lock_nonce!(child_class, child_context)
160
+ return unless child_class.respond_to?(:ordered_lock_config) && child_class.ordered_lock_config
163
161
 
164
- config = child_class.ordered_lock_config
165
- key = config[:key_proc].call(child_context.inputs)
166
- nonce, epoch = RubyReactor::OrderedLock.assign(key, ttl: config[:ttl])
162
+ config = child_class.ordered_lock_config
163
+ key = config[:key_proc].call(child_context.inputs)
164
+ nonce, epoch = RubyReactor::OrderedLock.assign(key, ttl: config[:ttl])
167
165
 
168
- child_context.private_data[:ordered_lock] = {
169
- key: key, nonce: nonce, epoch: epoch,
170
- poison_pill_timeout: config[:poison_pill_timeout],
171
- ttl: config[:ttl], strict: config.fetch(:strict, true)
172
- }
173
- end
166
+ child_context.private_data[:ordered_lock] = {
167
+ key: key, nonce: nonce, epoch: epoch,
168
+ poison_pill_timeout: config[:poison_pill_timeout],
169
+ ttl: config[:ttl], strict: config.fetch(:strict, true)
170
+ }
171
+ end
174
172
 
175
- def store_reference(context, execution_id:, child_class:)
176
- context.composed_contexts[context.current_step] = {
177
- name: context.current_step,
178
- type: :async_reactor_ref,
179
- execution_id: execution_id,
180
- reactor_class_name: RubyReactor.reactor_storage_name(child_class),
181
- dispatched_at: Time.now
182
- }
183
- end
173
+ def store_reference(execution_id:, child_class:)
174
+ context.composed_contexts[context.current_step] = {
175
+ name: context.current_step,
176
+ type: :async_reactor_ref,
177
+ execution_id: execution_id,
178
+ reactor_class_name: RubyReactor.reactor_storage_name(child_class),
179
+ dispatched_at: Time.now
180
+ }
181
+ end
184
182
 
185
- def save(child_context, child_class)
186
- RubyReactor.configuration.storage_adapter.store_context(
187
- child_context.context_id,
188
- RubyReactor::ContextSerializer.serialize(child_context),
189
- RubyReactor.reactor_storage_name(child_class)
190
- )
191
- end
183
+ def save(child_context, child_class)
184
+ RubyReactor.configuration.storage_adapter.store_context(
185
+ child_context.context_id,
186
+ RubyReactor::ContextSerializer.serialize(child_context),
187
+ RubyReactor.reactor_storage_name(child_class)
188
+ )
189
+ end
192
190
 
193
- # The link between parent and child, machine-parseable. Matters
194
- # most here — a fire-and-forget child's failure may have no other surface
195
- # in the parent at all.
196
- def log_dispatch(context, child_class, child_context)
197
- RubyReactor.configuration.logger.info(
198
- "event=\"ruby_reactor.async_reactor.dispatched\" " \
199
- "reactor=#{context.reactor_class&.name.inspect} step=#{context.current_step.inspect} " \
200
- "execution_id=#{context.context_id.inspect} child_reactor=#{child_class.name.inspect} " \
201
- "child_execution_id=#{child_context.context_id.inspect}"
202
- )
203
- end
191
+ # The link between parent and child, machine-parseable. Matters
192
+ # most here — a fire-and-forget child's failure may have no other surface
193
+ # in the parent at all.
194
+ def log_dispatch(child_class, child_context)
195
+ RubyReactor.configuration.logger.info(
196
+ "event=\"ruby_reactor.async_reactor.dispatched\" " \
197
+ "reactor=#{context.reactor_class&.name.inspect} step=#{context.current_step.inspect} " \
198
+ "execution_id=#{context.context_id.inspect} child_reactor=#{child_class.name.inspect} " \
199
+ "child_execution_id=#{child_context.context_id.inspect}"
200
+ )
204
201
  end
205
202
  end
206
203
  end
@@ -1,121 +1,102 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- module Step
5
- class ComposeStep
6
- include RubyReactor::Step
7
-
8
- attr_reader :composed_reactor_class, :argument_mappings
9
-
10
- def initialize(composed_reactor_class, argument_mappings = {})
11
- @composed_reactor_class = composed_reactor_class
12
- @argument_mappings = argument_mappings
13
- end
14
-
15
- def self.run(arguments, context)
4
+ class Step
5
+ class ComposeStep < RubyReactor::Step
6
+ def run
16
7
  step_name = context.current_step
17
8
  composed_data = context.composed_contexts[step_name]
18
- child_context = prepare_child_context(arguments, context, composed_data)
9
+ child_context = prepare_child_context(composed_data)
19
10
 
20
11
  # Store the child context in composed_contexts BEFORE execution
21
- store_child_context(context, step_name, child_context)
12
+ store_child_context(step_name, child_context)
22
13
 
23
14
  # Execute the composed reactor
24
- result = execute_child_reactor(arguments[:composed_reactor_class], child_context, composed_data)
15
+ result = execute_child_reactor(inputs[:composed_reactor_class], child_context, composed_data)
25
16
 
26
17
  # Update the stored context
27
- store_child_context(context, step_name, child_context)
18
+ store_child_context(step_name, child_context)
28
19
 
29
20
  handle_execution_result(result)
30
21
  end
31
22
 
32
- def self.compensate(_reason, arguments, context)
23
+ # Compensating a failed compose and undoing a completed one are the same
24
+ # work: roll back whatever the child reactor completed.
25
+ def compensate
33
26
  step_name = context.current_step
34
27
  composed_data = context.composed_contexts[step_name]
35
28
  return RubyReactor.Success() unless composed_data && composed_data[:context]
36
29
 
37
30
  child_context = composed_data[:context]
38
- executor = RubyReactor::Executor.new(arguments[:composed_reactor_class], {}, child_context)
31
+ executor = RubyReactor::Executor.new(inputs[:composed_reactor_class], {}, child_context)
39
32
  executor.undo_all
40
33
  executor.save_context
41
34
 
42
35
  RubyReactor.Success()
43
36
  end
44
37
 
45
- def self.undo(_result, arguments, context)
46
- step_name = context.current_step
47
- composed_data = context.composed_contexts[step_name]
48
- return RubyReactor.Success() unless composed_data && composed_data[:context]
38
+ alias undo compensate
49
39
 
50
- child_context = composed_data[:context]
51
- executor = RubyReactor::Executor.new(arguments[:composed_reactor_class], {}, child_context)
52
- executor.undo_all
53
- executor.save_context
40
+ private
54
41
 
55
- RubyReactor.Success()
56
- end
42
+ def build_composed_inputs(mappings)
43
+ built = {}
57
44
 
58
- class << self
59
- private
45
+ mappings.each do |composed_input_name, source|
46
+ value = source.resolve(context)
47
+ built[composed_input_name] = value
48
+ end
60
49
 
61
- def build_composed_inputs(mappings, context)
62
- inputs = {}
50
+ built
51
+ end
63
52
 
64
- mappings.each do |composed_input_name, source|
65
- value = source.resolve(context)
66
- inputs[composed_input_name] = value
67
- end
53
+ def prepare_child_context(composed_data)
54
+ child_context = composed_data ? composed_data[:context] : nil
68
55
 
69
- inputs
56
+ unless child_context
57
+ composed_inputs = build_composed_inputs(inputs[:argument_mappings] || {})
58
+ child_context = RubyReactor::Context.new(composed_inputs, inputs[:composed_reactor_class])
70
59
  end
71
60
 
72
- def prepare_child_context(arguments, context, composed_data)
73
- child_context = composed_data ? composed_data[:context] : nil
61
+ link_contexts(child_context, context)
62
+ child_context
63
+ end
74
64
 
75
- unless child_context
76
- composed_inputs = build_composed_inputs(arguments[:argument_mappings] || {}, context)
77
- child_context = RubyReactor::Context.new(composed_inputs, arguments[:composed_reactor_class])
78
- end
65
+ def link_contexts(child_context, parent_context)
66
+ child_context.parent_context = parent_context
67
+ child_context.root_context = parent_context.root_context || parent_context
68
+ child_context.inline_async_execution = parent_context.inline_async_execution
69
+ end
79
70
 
80
- link_contexts(child_context, context)
81
- child_context
82
- end
71
+ def store_child_context(step_name, child_context)
72
+ context.composed_contexts[step_name] = {
73
+ name: step_name,
74
+ type: :composed,
75
+ context: child_context
76
+ }
77
+ end
83
78
 
84
- def link_contexts(child_context, parent_context)
85
- child_context.parent_context = parent_context
86
- child_context.root_context = parent_context.root_context || parent_context
87
- child_context.inline_async_execution = parent_context.inline_async_execution
88
- end
79
+ def execute_child_reactor(composed_reactor, child_context, composed_data)
80
+ executor = RubyReactor::Executor.new(composed_reactor, {}, child_context)
89
81
 
90
- def store_child_context(context, step_name, child_context)
91
- context.composed_contexts[step_name] = {
92
- name: step_name,
93
- type: :composed,
94
- context: child_context
95
- }
82
+ if composed_data && child_context.current_step
83
+ executor.resume_execution
84
+ else
85
+ executor.execute
96
86
  end
97
87
 
98
- def execute_child_reactor(composed_reactor, child_context, composed_data)
99
- executor = RubyReactor::Executor.new(composed_reactor, {}, child_context)
88
+ executor.result
89
+ end
100
90
 
101
- if composed_data && child_context.current_step
102
- executor.resume_execution
103
- else
104
- executor.execute
105
- end
91
+ def handle_execution_result(result)
92
+ return result if result.is_a?(RubyReactor::DispatchResult) || result.is_a?(RubyReactor::RetryQueuedResult)
106
93
 
107
- executor.result
108
- end
94
+ # Hand the child's Failure through untouched: rebuilding it from
95
+ # `error` alone drops validation_errors, retryability and the rest of
96
+ # the metadata the direct and async paths do propagate.
97
+ return result if result.is_a?(RubyReactor::Failure)
109
98
 
110
- def handle_execution_result(result)
111
- return result if result.is_a?(RubyReactor::DispatchResult) || result.is_a?(RubyReactor::RetryQueuedResult)
112
-
113
- if result.success?
114
- RubyReactor.Success(result.value)
115
- else
116
- RubyReactor.Failure(result.error)
117
- end
118
- end
99
+ result.success? ? RubyReactor.Success(result.value) : RubyReactor.Failure(result.error)
119
100
  end
120
101
  end
121
102
  end