ruby_reactor 0.8.3 → 0.8.4
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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/.specify/feature.json +1 -1
- data/CHANGELOG.md +12 -0
- data/CLAUDE.md +1 -1
- data/README.md +93 -91
- data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
- data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
- data/lib/ruby_reactor/dsl/reactor.rb +8 -11
- data/lib/ruby_reactor/dsl/retryable.rb +45 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +40 -14
- data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +2 -2
- data/lib/ruby_reactor/executor/result_handler.rb +3 -2
- data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
- data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
- data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
- data/lib/ruby_reactor/step/compose_step.rb +8 -4
- data/lib/ruby_reactor/step/input_contract.rb +5 -0
- data/lib/ruby_reactor/step/inputs.rb +57 -0
- data/lib/ruby_reactor/step/map_step.rb +32 -22
- data/lib/ruby_reactor/step.rb +13 -7
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor.rb +3 -1
- data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
- data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
- data/specs/006-step-retry-declarations/data-model.md +58 -0
- data/specs/006-step-retry-declarations/plan.md +187 -0
- data/specs/006-step-retry-declarations/quickstart.md +80 -0
- data/specs/006-step-retry-declarations/research.md +194 -0
- data/specs/006-step-retry-declarations/spec.md +453 -0
- data/specs/006-step-retry-declarations/tasks.md +382 -0
- data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
- metadata +13 -1
|
@@ -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,6 +6,7 @@ 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",
|
|
@@ -13,7 +14,7 @@ module RubyReactor
|
|
|
13
14
|
}.freeze
|
|
14
15
|
|
|
15
16
|
attr_accessor :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
|
|
16
|
-
:dependencies, :args_validator, :output_validator
|
|
17
|
+
:dependencies, :args_validator, :output_validator
|
|
17
18
|
|
|
18
19
|
def initialize(name, impl = nil, reactor = nil)
|
|
19
20
|
@name = name
|
|
@@ -30,7 +31,7 @@ module RubyReactor
|
|
|
30
31
|
@validate_args_input = nil
|
|
31
32
|
@args_validator = nil
|
|
32
33
|
@output_validator = nil
|
|
33
|
-
@retry_config =
|
|
34
|
+
@retry_config = nil
|
|
34
35
|
@inline_contract = nil
|
|
35
36
|
@rule_sites = []
|
|
36
37
|
end
|
|
@@ -130,20 +131,13 @@ module RubyReactor
|
|
|
130
131
|
)
|
|
131
132
|
end
|
|
132
133
|
|
|
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
134
|
# `async_dispatch` marks a step whose work is dispatched as an independent
|
|
142
135
|
# unit rather than run inline — `:step` for `async_step`, `:reactor` for
|
|
143
136
|
# `async_reactor`. Nil for an ordinary step.
|
|
144
137
|
def build(async_dispatch: nil)
|
|
145
138
|
check_contract_conflicts!
|
|
146
139
|
check_coordination_conflicts!
|
|
140
|
+
check_retry_conflict!
|
|
147
141
|
warn_deprecated_rules
|
|
148
142
|
|
|
149
143
|
step_config = {
|
|
@@ -160,7 +154,7 @@ module RubyReactor
|
|
|
160
154
|
args_validator: @args_validator || build_args_validator(@arg_validations, @validate_args_input),
|
|
161
155
|
output_validator: @output_validator,
|
|
162
156
|
inline_contract: @inline_contract,
|
|
163
|
-
retry_config: @retry_config
|
|
157
|
+
retry_config: @retry_config,
|
|
164
158
|
lock_config: @lock_config,
|
|
165
159
|
semaphore_config: @semaphore_config,
|
|
166
160
|
rate_limit_config: @rate_limit_config,
|
|
@@ -190,6 +184,16 @@ module RubyReactor
|
|
|
190
184
|
end
|
|
191
185
|
end
|
|
192
186
|
|
|
187
|
+
# Same rule as the coordination macros: one retry policy per step.
|
|
188
|
+
def check_retry_conflict!
|
|
189
|
+
return unless @retry_config && @impl.respond_to?(:retry_config) && @impl.retry_config
|
|
190
|
+
|
|
191
|
+
raise Error::ValidationError,
|
|
192
|
+
"#{reactor_label} step :#{@name} declares `retries` inline, but #{@impl} declares it too. " \
|
|
193
|
+
"Keep ONE: drop the inline declaration to use #{@impl}'s, or remove it from #{@impl}. " \
|
|
194
|
+
"To vary the policy per workflow, subclass #{@impl} and declare `retries` there."
|
|
195
|
+
end
|
|
196
|
+
|
|
193
197
|
# A step that owns its input contract takes wiring only from the
|
|
194
198
|
# reactor: rules here would be a second, overlapping rule set.
|
|
195
199
|
def check_contract_conflicts!
|
|
@@ -250,8 +254,10 @@ module RubyReactor
|
|
|
250
254
|
end
|
|
251
255
|
|
|
252
256
|
class StepConfig
|
|
257
|
+
NO_RETRIES = { max_attempts: 1, backoff: :exponential, base_delay: 1 }.freeze
|
|
258
|
+
|
|
253
259
|
attr_reader :name, :impl, :arguments, :run_block, :compensate_block, :undo_block, :conditions, :guards,
|
|
254
|
-
:dependencies, :args_validator, :output_validator, :
|
|
260
|
+
:dependencies, :args_validator, :output_validator, :async_dispatch,
|
|
255
261
|
:inline_contract
|
|
256
262
|
|
|
257
263
|
def initialize(config)
|
|
@@ -268,7 +274,7 @@ module RubyReactor
|
|
|
268
274
|
@args_validator = config[:args_validator]
|
|
269
275
|
@output_validator = config[:output_validator]
|
|
270
276
|
@inline_contract = config[:inline_contract]
|
|
271
|
-
@retry_config =
|
|
277
|
+
@retry_config = config[:retry_config]
|
|
272
278
|
@lock_config = config[:lock_config]
|
|
273
279
|
@semaphore_config = config[:semaphore_config]
|
|
274
280
|
@rate_limit_config = config[:rate_limit_config]
|
|
@@ -287,6 +293,21 @@ module RubyReactor
|
|
|
287
293
|
@lock_config || (impl.lock_config if impl.respond_to?(:lock_config))
|
|
288
294
|
end
|
|
289
295
|
|
|
296
|
+
# A step's EFFECTIVE retry policy, resolved like the coordination
|
|
297
|
+
# readers: its own (step block) declaration, else the class step's,
|
|
298
|
+
# else a single attempt. Never nil. The reactor is never consulted.
|
|
299
|
+
def retry_config
|
|
300
|
+
@retry_config || (impl.retry_config if impl.respond_to?(:retry_config)) || NO_RETRIES
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Where `retry_config` came from: :step_block, :step_class or :none.
|
|
304
|
+
def retry_source
|
|
305
|
+
return :step_block if @retry_config
|
|
306
|
+
return :step_class if impl.respond_to?(:retry_config) && impl.retry_config
|
|
307
|
+
|
|
308
|
+
:none
|
|
309
|
+
end
|
|
310
|
+
|
|
290
311
|
def semaphore_config
|
|
291
312
|
@semaphore_config || (impl.semaphore_config if impl.respond_to?(:semaphore_config))
|
|
292
313
|
end
|
|
@@ -343,7 +364,7 @@ module RubyReactor
|
|
|
343
364
|
def call_body(arguments, context)
|
|
344
365
|
catch(StepSignals::TAG) do
|
|
345
366
|
if has_run_block?
|
|
346
|
-
run_block.call(arguments, context)
|
|
367
|
+
run_block.call(wrap_inputs(arguments), context)
|
|
347
368
|
elsif impl.respond_to?(:run_without_coordination)
|
|
348
369
|
impl.run_without_coordination(arguments, context)
|
|
349
370
|
else
|
|
@@ -373,6 +394,11 @@ module RubyReactor
|
|
|
373
394
|
!@run_block.nil?
|
|
374
395
|
end
|
|
375
396
|
|
|
397
|
+
# What an inline `run`/`undo`/`compensate` block receives as its inputs.
|
|
398
|
+
def wrap_inputs(arguments)
|
|
399
|
+
RubyReactor::Step::Inputs.new(arguments, contract: input_contract, owner: "step :#{name}")
|
|
400
|
+
end
|
|
401
|
+
|
|
376
402
|
def retryable?
|
|
377
403
|
(retry_config[:max_attempts] || 0) > 1
|
|
378
404
|
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
|
|
@@ -123,7 +123,7 @@ module RubyReactor
|
|
|
123
123
|
compensate_result = coordinated_rollback(step_config, arguments) do
|
|
124
124
|
catch(StepSignals::TAG) do
|
|
125
125
|
if step_config.compensate_block
|
|
126
|
-
step_config.compensate_block.call(error, arguments, @context)
|
|
126
|
+
step_config.compensate_block.call(error, step_config.wrap_inputs(arguments), @context)
|
|
127
127
|
elsif step_config.has_impl?
|
|
128
128
|
step_config.impl.compensate(error, arguments, @context)
|
|
129
129
|
else
|
|
@@ -168,7 +168,7 @@ module RubyReactor
|
|
|
168
168
|
undo_result = coordinated_rollback(step_config, arguments) do
|
|
169
169
|
catch(StepSignals::TAG) do
|
|
170
170
|
if step_config.undo_block
|
|
171
|
-
step_config.undo_block.call(result.value, arguments, @context)
|
|
171
|
+
step_config.undo_block.call(result.value, step_config.wrap_inputs(arguments), @context)
|
|
172
172
|
elsif step_config.has_impl?
|
|
173
173
|
step_config.impl.undo(result.value, arguments, @context)
|
|
174
174
|
else
|
|
@@ -188,13 +188,14 @@ module RubyReactor
|
|
|
188
188
|
raise error
|
|
189
189
|
end
|
|
190
190
|
|
|
191
|
+
# Wrapped first, so a returned `Step::Inputs` is validated and stored as its Hash.
|
|
191
192
|
def handle_unknown_result(step_config, result, resolved_arguments)
|
|
192
|
-
validate_step_output(step_config, result, resolved_arguments)
|
|
193
193
|
success_result = RubyReactor.Success(result)
|
|
194
|
+
validate_step_output(step_config, success_result.value, resolved_arguments)
|
|
194
195
|
@step_results[step_config.name] = success_result
|
|
195
196
|
@compensation_manager.add_to_undo_stack({ step: step_config, arguments: resolved_arguments,
|
|
196
197
|
result: success_result })
|
|
197
|
-
@context.set_result(step_config.name,
|
|
198
|
+
@context.set_result(step_config.name, success_result.value)
|
|
198
199
|
@dependency_graph.complete_step(step_config.name)
|
|
199
200
|
end
|
|
200
201
|
|
|
@@ -24,19 +24,18 @@ module RubyReactor
|
|
|
24
24
|
step_config.retry_config[:max_attempts])
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def calculate_backoff_delay(step_config
|
|
27
|
+
def calculate_backoff_delay(step_config)
|
|
28
28
|
attempt_number = @context.retry_context.attempts_for_step(step_config.name)
|
|
29
|
-
|
|
30
|
-
base_delay = step_config.retry_config[:base_delay] || reactor_class.retry_defaults[:base_delay]
|
|
29
|
+
retry_config = step_config.retry_config
|
|
31
30
|
|
|
32
|
-
delay = RetryContext.calculate_backoff_delay(attempt_number,
|
|
31
|
+
delay = RetryContext.calculate_backoff_delay(attempt_number, retry_config[:backoff], retry_config[:base_delay])
|
|
33
32
|
@context.retry_context.next_retry_at = Time.now + delay
|
|
34
33
|
delay
|
|
35
34
|
end
|
|
36
35
|
|
|
37
|
-
def requeue_job_for_step_retry(step_config
|
|
36
|
+
def requeue_job_for_step_retry(step_config)
|
|
38
37
|
@context.current_step = step_config.name
|
|
39
|
-
delay = calculate_backoff_delay(step_config
|
|
38
|
+
delay = calculate_backoff_delay(step_config)
|
|
40
39
|
|
|
41
40
|
requeue_job(step_config, delay)
|
|
42
41
|
end
|
|
@@ -147,14 +146,14 @@ module RubyReactor
|
|
|
147
146
|
|
|
148
147
|
# Always try async retry if configured
|
|
149
148
|
if is_async
|
|
150
|
-
handle_async_retry(step_config
|
|
149
|
+
handle_async_retry(step_config)
|
|
151
150
|
else
|
|
152
|
-
handle_sync_retry(step_config
|
|
151
|
+
handle_sync_retry(step_config)
|
|
153
152
|
end
|
|
154
153
|
end
|
|
155
154
|
|
|
156
|
-
def handle_async_retry(step_config
|
|
157
|
-
requeue_result = requeue_job_for_step_retry(step_config
|
|
155
|
+
def handle_async_retry(step_config)
|
|
156
|
+
requeue_result = requeue_job_for_step_retry(step_config)
|
|
158
157
|
|
|
159
158
|
# If it returned an DispatchResult, we are truly async.
|
|
160
159
|
# Otherwise, it ran inline and we should return the result of that execution.
|
|
@@ -169,8 +168,8 @@ module RubyReactor
|
|
|
169
168
|
end
|
|
170
169
|
end
|
|
171
170
|
|
|
172
|
-
def handle_sync_retry(step_config
|
|
173
|
-
delay = calculate_backoff_delay(step_config
|
|
171
|
+
def handle_sync_retry(step_config)
|
|
172
|
+
delay = calculate_backoff_delay(step_config)
|
|
174
173
|
sleep(delay)
|
|
175
174
|
nil # continue loop
|
|
176
175
|
end
|
|
@@ -556,7 +556,6 @@ module RubyReactor
|
|
|
556
556
|
@middlewares = superclass.middlewares.dup
|
|
557
557
|
@return_step = superclass.return_step
|
|
558
558
|
@background_handoff = superclass.background_handoff
|
|
559
|
-
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
560
559
|
|
|
561
560
|
# 2. Add Name Handling with Unique Registry Entry
|
|
562
561
|
# We must register a unique name so that if this reactor is reloaded (e.g. after async child completion),
|
|
@@ -648,7 +647,6 @@ module RubyReactor
|
|
|
648
647
|
@middlewares = superclass.middlewares.dup
|
|
649
648
|
@return_step = superclass.return_step
|
|
650
649
|
@background_handoff = superclass.background_handoff
|
|
651
|
-
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
652
650
|
end
|
|
653
651
|
|
|
654
652
|
strip_background_and_async!(child_class)
|
|
@@ -736,7 +734,6 @@ module RubyReactor
|
|
|
736
734
|
@middlewares = superclass.middlewares.dup
|
|
737
735
|
@return_step = superclass.return_step
|
|
738
736
|
@background_handoff = superclass.background_handoff
|
|
739
|
-
@retry_defaults = superclass.instance_variable_get(:@retry_defaults)
|
|
740
737
|
end
|
|
741
738
|
|
|
742
739
|
# Recursively apply interceptors to the child reactor
|
|
@@ -765,14 +762,15 @@ module RubyReactor
|
|
|
765
762
|
# callable a mock block can invoke.
|
|
766
763
|
def original_impl_for(step_config_orig, target_step)
|
|
767
764
|
if step_config_orig.has_run_block?
|
|
768
|
-
|
|
765
|
+
# A mock may hand `original` a plain Hash (`args.to_h.merge(...)`).
|
|
766
|
+
->(args, ctx) { step_config_orig.run_block.call(step_config_orig.wrap_inputs(args), ctx) }
|
|
769
767
|
elsif step_config_orig.has_impl?
|
|
770
768
|
impl = step_config_orig.impl
|
|
771
769
|
|
|
772
770
|
if impl.respond_to?(:run_without_coordination)
|
|
773
771
|
->(args, ctx) { impl.run_without_coordination(args, ctx) }
|
|
774
772
|
else
|
|
775
|
-
->(args, ctx) { impl.run(args, ctx) }
|
|
773
|
+
->(args, ctx) { impl.run(args.to_h, ctx) }
|
|
776
774
|
end
|
|
777
775
|
else
|
|
778
776
|
->(_, _) { raise "No implementation found for #{target_step}" }
|
|
@@ -15,6 +15,10 @@ module RubyReactor
|
|
|
15
15
|
# ordered-lock nonce at ENQUEUE time (so ordering matches caller order), and
|
|
16
16
|
# persist before enqueueing (F2).
|
|
17
17
|
class AsyncReactorStep < RubyReactor::Step
|
|
18
|
+
# Untyped, so no validation: declared only so `inputs.x` can read them.
|
|
19
|
+
input :async_reactor_class
|
|
20
|
+
input :argument_mappings, optional: true
|
|
21
|
+
|
|
18
22
|
class << self
|
|
19
23
|
# Exclusive keys this EXECUTION currently holds, read from the root
|
|
20
24
|
# context's registry. Shared by the `async_reactor` dispatch check
|
|
@@ -54,8 +58,8 @@ module RubyReactor
|
|
|
54
58
|
end
|
|
55
59
|
|
|
56
60
|
def run
|
|
57
|
-
child_class = inputs
|
|
58
|
-
child_inputs = build_child_inputs(inputs
|
|
61
|
+
child_class = inputs.async_reactor_class
|
|
62
|
+
child_inputs = build_child_inputs(inputs.argument_mappings || {})
|
|
59
63
|
|
|
60
64
|
# A dispatch-time failure fails the DISPATCHING step, i.e. normal saga
|
|
61
65
|
# handling in the parent. That is deliberately outside the
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
class Step
|
|
5
5
|
class ComposeStep < RubyReactor::Step
|
|
6
|
+
# Untyped, so no validation: declared only so `inputs.x` can read them.
|
|
7
|
+
input :composed_reactor_class
|
|
8
|
+
input :argument_mappings, optional: true
|
|
9
|
+
|
|
6
10
|
def run
|
|
7
11
|
step_name = context.current_step
|
|
8
12
|
composed_data = context.composed_contexts[step_name]
|
|
@@ -12,7 +16,7 @@ module RubyReactor
|
|
|
12
16
|
store_child_context(step_name, child_context)
|
|
13
17
|
|
|
14
18
|
# Execute the composed reactor
|
|
15
|
-
result = execute_child_reactor(inputs
|
|
19
|
+
result = execute_child_reactor(inputs.composed_reactor_class, child_context, composed_data)
|
|
16
20
|
|
|
17
21
|
# Update the stored context
|
|
18
22
|
store_child_context(step_name, child_context)
|
|
@@ -30,7 +34,7 @@ module RubyReactor
|
|
|
30
34
|
return RubyReactor.Success() unless composed_data && composed_data[:context]
|
|
31
35
|
|
|
32
36
|
child_context = composed_data[:context]
|
|
33
|
-
executor = RubyReactor::Executor.new(inputs
|
|
37
|
+
executor = RubyReactor::Executor.new(inputs.composed_reactor_class, {}, child_context)
|
|
34
38
|
executor.undo_all
|
|
35
39
|
executor.save_context
|
|
36
40
|
|
|
@@ -59,8 +63,8 @@ module RubyReactor
|
|
|
59
63
|
child_context = composed_data ? composed_data[:context] : nil
|
|
60
64
|
|
|
61
65
|
unless child_context
|
|
62
|
-
composed_inputs = build_composed_inputs(inputs
|
|
63
|
-
child_context = RubyReactor::Context.new(composed_inputs, inputs
|
|
66
|
+
composed_inputs = build_composed_inputs(inputs.argument_mappings || {})
|
|
67
|
+
child_context = RubyReactor::Context.new(composed_inputs, inputs.composed_reactor_class)
|
|
64
68
|
end
|
|
65
69
|
|
|
66
70
|
link_contexts(child_context, context)
|
|
@@ -25,6 +25,11 @@ module RubyReactor
|
|
|
25
25
|
def input(name, type = nil, optional: false, default: nil, redact: false, validate: nil, **predicates, &block)
|
|
26
26
|
# rubocop:enable Metrics/ParameterLists
|
|
27
27
|
check_dry_validation_available!
|
|
28
|
+
if Step::Inputs.public_method_defined?(name)
|
|
29
|
+
raise Error::ValidationError,
|
|
30
|
+
"input :#{name} is reserved: step code reads inputs as `inputs.#{name}`, and `#{name}` is already " \
|
|
31
|
+
"a method there. Rename the input."
|
|
32
|
+
end
|
|
28
33
|
unless default.nil? || optional
|
|
29
34
|
raise Error::ValidationError,
|
|
30
35
|
"input :#{name} declares `default:` but is required; a default only applies to an " \
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyReactor
|
|
4
|
+
class Step
|
|
5
|
+
# What step code (`run`, `undo`, `compensate`) receives as its inputs: a
|
|
6
|
+
# frozen, read-only view of the argument Hash with one reader per name the
|
|
7
|
+
# step may read. Any other name raises `Error::UndeclaredInputError` on the
|
|
8
|
+
# line that reads it, instead of returning nil and failing somewhere else.
|
|
9
|
+
#
|
|
10
|
+
# inputs.order_guid # the value, or nil for an absent optional input
|
|
11
|
+
# inputs.order_id # raises UndeclaredInputError
|
|
12
|
+
# inputs.to_h # supplied values, readable names only
|
|
13
|
+
# Service.call(**inputs) # via `to_hash`
|
|
14
|
+
#
|
|
15
|
+
# Readable names are the contract's declarations, or the keys present when
|
|
16
|
+
# the step has none. Built only where inputs reach step code; everything
|
|
17
|
+
# else in the gem keeps the Hash.
|
|
18
|
+
class Inputs
|
|
19
|
+
def initialize(values, contract: nil, owner: nil)
|
|
20
|
+
@values = values.to_h
|
|
21
|
+
@owner = owner
|
|
22
|
+
declared = contract&.declarations&.keys || []
|
|
23
|
+
@names = declared.empty? ? @values.keys.map(&:to_sym).uniq : declared
|
|
24
|
+
@redacted = contract&.redacted_names || []
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
@names.each_with_object({}) do |name, hash|
|
|
30
|
+
hash[name] = Utils::FetchIndifferent.call(@values, name) if supplied?(name)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
alias to_hash to_h
|
|
34
|
+
|
|
35
|
+
def inspect
|
|
36
|
+
to_h.to_h { |name, value| [name, @redacted.include?(name) ? InputContract::REDACTED : value] }.inspect
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def method_missing(name, *args)
|
|
42
|
+
return Utils::FetchIndifferent.call(@values, name) if args.empty? && @names.include?(name)
|
|
43
|
+
|
|
44
|
+
declared = @names.empty? ? "none" : @names.map(&:inspect).join(", ")
|
|
45
|
+
raise Error::UndeclaredInputError.new("#{@owner} has no input :#{name}. Declared inputs: #{declared}.", name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def respond_to_missing?(name, include_private = false)
|
|
49
|
+
@names.include?(name) || super
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def supplied?(name)
|
|
53
|
+
@values.key?(name) || @values.key?(name.to_s)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -3,8 +3,18 @@
|
|
|
3
3
|
module RubyReactor
|
|
4
4
|
class Step
|
|
5
5
|
class MapStep < RubyReactor::Step
|
|
6
|
+
# Untyped, so no validation: declared only so `inputs.x` can read them.
|
|
7
|
+
input :source
|
|
8
|
+
input :mapped_reactor_class
|
|
9
|
+
input :argument_mappings, optional: true
|
|
10
|
+
input :strict_ordering, optional: true
|
|
11
|
+
input :batch_size, optional: true
|
|
12
|
+
input :collect_block, optional: true
|
|
13
|
+
input :fail_fast, optional: true
|
|
14
|
+
input :fan_out, optional: true
|
|
15
|
+
|
|
6
16
|
def run
|
|
7
|
-
return RubyReactor::Failure("Map source cannot be nil") if inputs
|
|
17
|
+
return RubyReactor::Failure("Map source cannot be nil") if inputs.source.nil?
|
|
8
18
|
|
|
9
19
|
# Initialize map state in context if not present
|
|
10
20
|
context.map_operations ||= {}
|
|
@@ -77,21 +87,21 @@ module RubyReactor
|
|
|
77
87
|
def fan_out?
|
|
78
88
|
return false if context.map_metadata || context.root_context&.map_metadata
|
|
79
89
|
|
|
80
|
-
inputs
|
|
90
|
+
inputs.fan_out
|
|
81
91
|
end
|
|
82
92
|
|
|
83
93
|
def run_inline
|
|
84
94
|
results = execute_inline_map
|
|
85
95
|
return results if results.is_a?(RubyReactor::Failure) || results.is_a?(RubyReactor::Halt)
|
|
86
96
|
|
|
87
|
-
process_results(results, inputs
|
|
97
|
+
process_results(results, inputs.collect_block, inputs.fail_fast)
|
|
88
98
|
end
|
|
89
99
|
|
|
90
100
|
def execute_inline_map
|
|
91
101
|
results = []
|
|
92
|
-
fail_fast = inputs
|
|
102
|
+
fail_fast = inputs.fail_fast.nil? || inputs.fail_fast
|
|
93
103
|
|
|
94
|
-
inputs
|
|
104
|
+
inputs.source.each_with_index do |element, index|
|
|
95
105
|
result = execute_single_element(element, index)
|
|
96
106
|
|
|
97
107
|
# An element-level Halt propagates as a run halt: stop immediately
|
|
@@ -110,8 +120,8 @@ module RubyReactor
|
|
|
110
120
|
end
|
|
111
121
|
|
|
112
122
|
def execute_single_element(element, index)
|
|
113
|
-
mapped_inputs = self.class.build_mapped_inputs(inputs
|
|
114
|
-
child_context = RubyReactor::Context.new(mapped_inputs, inputs
|
|
123
|
+
mapped_inputs = self.class.build_mapped_inputs(inputs.argument_mappings || {}, context, element)
|
|
124
|
+
child_context = RubyReactor::Context.new(mapped_inputs, inputs.mapped_reactor_class)
|
|
115
125
|
|
|
116
126
|
link_contexts(child_context, context)
|
|
117
127
|
|
|
@@ -131,10 +141,10 @@ module RubyReactor
|
|
|
131
141
|
name: context.current_step,
|
|
132
142
|
type: :map_ref,
|
|
133
143
|
map_id: map_id,
|
|
134
|
-
element_reactor_class: inputs
|
|
144
|
+
element_reactor_class: inputs.mapped_reactor_class.name
|
|
135
145
|
}
|
|
136
146
|
|
|
137
|
-
executor = RubyReactor::Executor.new(inputs
|
|
147
|
+
executor = RubyReactor::Executor.new(inputs.mapped_reactor_class, {}, child_context)
|
|
138
148
|
executor.execute
|
|
139
149
|
executor.result
|
|
140
150
|
end
|
|
@@ -162,9 +172,9 @@ module RubyReactor
|
|
|
162
172
|
def run_async(step_name)
|
|
163
173
|
map_id = "#{context.context_id}:#{step_name}"
|
|
164
174
|
context.map_operations[step_name.to_s] = map_id
|
|
165
|
-
prepare_async_execution(map_id, inputs
|
|
175
|
+
prepare_async_execution(map_id, inputs.source.size)
|
|
166
176
|
|
|
167
|
-
reactor_class_info = build_reactor_class_info(inputs
|
|
177
|
+
reactor_class_info = build_reactor_class_info(inputs.mapped_reactor_class, step_name)
|
|
168
178
|
|
|
169
179
|
initialize_map_metadata(map_id, reactor_class_info)
|
|
170
180
|
|
|
@@ -175,7 +185,7 @@ module RubyReactor
|
|
|
175
185
|
name: step_name.to_s,
|
|
176
186
|
type: :map_ref,
|
|
177
187
|
map_id: map_id,
|
|
178
|
-
element_reactor_class: inputs
|
|
188
|
+
element_reactor_class: inputs.mapped_reactor_class.name
|
|
179
189
|
}
|
|
180
190
|
|
|
181
191
|
RubyReactor::DispatchResult.new(
|
|
@@ -188,9 +198,9 @@ module RubyReactor
|
|
|
188
198
|
def initialize_map_metadata(map_id, reactor_class_info)
|
|
189
199
|
storage = RubyReactor.configuration.storage_adapter
|
|
190
200
|
storage.initialize_map_operation(
|
|
191
|
-
map_id, inputs
|
|
192
|
-
strict_ordering: inputs
|
|
193
|
-
**map_recovery_metadata(
|
|
201
|
+
map_id, inputs.source.size, context.reactor_class.name,
|
|
202
|
+
strict_ordering: inputs.strict_ordering, reactor_class_info: reactor_class_info,
|
|
203
|
+
**map_recovery_metadata(context.current_step)
|
|
194
204
|
)
|
|
195
205
|
end
|
|
196
206
|
|
|
@@ -215,21 +225,21 @@ module RubyReactor
|
|
|
215
225
|
# own worker, with the map counter/collector tracking completion. This
|
|
216
226
|
# lets elements with async steps or async retries hand off correctly
|
|
217
227
|
# instead of being forced to run synchronously in a single worker.
|
|
218
|
-
batch_size = inputs
|
|
228
|
+
batch_size = inputs.batch_size || inputs.source.size
|
|
219
229
|
|
|
220
230
|
RubyReactor::Map::Dispatcher.perform(
|
|
221
231
|
map_id: map_id,
|
|
222
232
|
parent_context_id: context.context_id,
|
|
223
233
|
parent_reactor_class_name: context.reactor_class.name,
|
|
224
|
-
source: inputs
|
|
234
|
+
source: inputs.source,
|
|
225
235
|
batch_size: batch_size,
|
|
226
236
|
step_name: step_name,
|
|
227
|
-
argument_mappings: inputs
|
|
228
|
-
strict_ordering: inputs
|
|
229
|
-
mapped_reactor_class: inputs
|
|
230
|
-
fail_fast: inputs
|
|
237
|
+
argument_mappings: inputs.argument_mappings,
|
|
238
|
+
strict_ordering: inputs.strict_ordering,
|
|
239
|
+
mapped_reactor_class: inputs.mapped_reactor_class,
|
|
240
|
+
fail_fast: inputs.fail_fast.nil? || inputs.fail_fast
|
|
231
241
|
)
|
|
232
|
-
queue_collector(map_id, step_name, inputs
|
|
242
|
+
queue_collector(map_id, step_name, inputs.strict_ordering)
|
|
233
243
|
"map:#{map_id}"
|
|
234
244
|
end
|
|
235
245
|
|
data/lib/ruby_reactor/step.rb
CHANGED
|
@@ -5,13 +5,14 @@ module RubyReactor
|
|
|
5
5
|
#
|
|
6
6
|
# class MyStep < RubyReactor::Step
|
|
7
7
|
# input :amount, :integer
|
|
8
|
-
# def run = Success(charged: inputs
|
|
8
|
+
# def run = Success(charged: inputs.amount)
|
|
9
9
|
# end
|
|
10
10
|
#
|
|
11
11
|
# Lifecycle of every class-level call (`.run`/`.call`, `.undo`, `.compensate`):
|
|
12
12
|
#
|
|
13
13
|
# 1. Resolve `inputs`: the given arguments with the contract's defaults
|
|
14
|
-
# applied. `run`, `undo`, and `compensate` all see the same values
|
|
14
|
+
# applied. `run`, `undo`, and `compensate` all see the same values,
|
|
15
|
+
# through a read-only `Step::Inputs` (`inputs.amount`).
|
|
15
16
|
# 2. `.run` ONLY: enforce the declared input contract, raising
|
|
16
17
|
# `Error::InputValidationError` before any instance exists.
|
|
17
18
|
# 2b. `.run` ONLY: step-scoped coordination (`with_lock` etc, if declared)
|
|
@@ -30,17 +31,20 @@ module RubyReactor
|
|
|
30
31
|
# throw (`success!`/`skip!`/`fail!`/`halt!`) into its result wrapper.
|
|
31
32
|
#
|
|
32
33
|
# No `prepend`/`define_method`/`method_missing` — every step in the class
|
|
33
|
-
# reads top to bottom as ordinary method calls. The
|
|
34
|
-
# `Dsl::Lockable::ClassMethods` (the five coordination macros)
|
|
35
|
-
# self-contained
|
|
34
|
+
# reads top to bottom as ordinary method calls. The two `extend`s are
|
|
35
|
+
# `Dsl::Lockable::ClassMethods` (the five coordination macros) and
|
|
36
|
+
# `Dsl::Retryable` (`retries`), self-contained modules whose only hook is
|
|
37
|
+
# an `inherited` that copies their declarations down (Finding 9).
|
|
36
38
|
class Step
|
|
37
39
|
include RubyReactor::StepSignals
|
|
38
40
|
extend RubyReactor::Dsl::Lockable::ClassMethods
|
|
41
|
+
extend RubyReactor::Dsl::Retryable
|
|
39
42
|
|
|
40
43
|
attr_reader :inputs, :context, :result, :reason
|
|
41
44
|
|
|
42
45
|
def initialize(inputs, context, result: nil, reason: nil)
|
|
43
|
-
|
|
46
|
+
contract = self.class.input_contract if self.class.declares_inputs?
|
|
47
|
+
@inputs = Inputs.new(inputs, contract: contract, owner: self.class.name || self.class.inspect)
|
|
44
48
|
@context = context
|
|
45
49
|
@result = result
|
|
46
50
|
@reason = reason
|
|
@@ -163,11 +167,13 @@ module RubyReactor
|
|
|
163
167
|
@own_input_contract ||= Step::InputContract.new(owner: self)
|
|
164
168
|
end
|
|
165
169
|
|
|
170
|
+
# `to_h`: a step body may hand its own `Inputs` on (`OtherStep.run(inputs, context)`).
|
|
166
171
|
def with_defaults(arguments)
|
|
167
|
-
input_contract.apply_defaults(arguments)
|
|
172
|
+
input_contract.apply_defaults(arguments.to_h)
|
|
168
173
|
end
|
|
169
174
|
|
|
170
175
|
def enforce_contract!(arguments)
|
|
176
|
+
arguments = arguments.to_h
|
|
171
177
|
return arguments unless declares_inputs?
|
|
172
178
|
|
|
173
179
|
input_contract.enforce!(arguments)
|
data/lib/ruby_reactor/version.rb
CHANGED