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.
Files changed (34) 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 +12 -0
  5. data/CLAUDE.md +1 -1
  6. data/README.md +93 -91
  7. data/lib/ruby_reactor/dsl/async_reactor_builder.rb +3 -6
  8. data/lib/ruby_reactor/dsl/compose_builder.rb +3 -10
  9. data/lib/ruby_reactor/dsl/reactor.rb +8 -11
  10. data/lib/ruby_reactor/dsl/retryable.rb +45 -0
  11. data/lib/ruby_reactor/dsl/step_builder.rb +40 -14
  12. data/lib/ruby_reactor/error/undeclared_input_error.rb +13 -0
  13. data/lib/ruby_reactor/executor/compensation_manager.rb +2 -2
  14. data/lib/ruby_reactor/executor/result_handler.rb +3 -2
  15. data/lib/ruby_reactor/executor/retry_manager.rb +11 -12
  16. data/lib/ruby_reactor/rspec/test_subject.rb +3 -5
  17. data/lib/ruby_reactor/step/async_reactor_step.rb +6 -2
  18. data/lib/ruby_reactor/step/compose_step.rb +8 -4
  19. data/lib/ruby_reactor/step/input_contract.rb +5 -0
  20. data/lib/ruby_reactor/step/inputs.rb +57 -0
  21. data/lib/ruby_reactor/step/map_step.rb +32 -22
  22. data/lib/ruby_reactor/step.rb +13 -7
  23. data/lib/ruby_reactor/version.rb +1 -1
  24. data/lib/ruby_reactor.rb +3 -1
  25. data/specs/006-step-retry-declarations/checklists/requirements.md +41 -0
  26. data/specs/006-step-retry-declarations/contracts/dsl-surface.md +89 -0
  27. data/specs/006-step-retry-declarations/data-model.md +58 -0
  28. data/specs/006-step-retry-declarations/plan.md +187 -0
  29. data/specs/006-step-retry-declarations/quickstart.md +80 -0
  30. data/specs/006-step-retry-declarations/research.md +194 -0
  31. data/specs/006-step-retry-declarations/spec.md +453 -0
  32. data/specs/006-step-retry-declarations/tasks.md +382 -0
  33. data/specs/specs-inputs-by-method-md-piped-wigderson.md +77 -0
  34. 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, :retry_config
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.empty? ? (@reactor&.retry_defaults || {}) : @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, :retry_config, :async_dispatch,
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 = { max_attempts: 1 }.merge(config[: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, result)
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, _error, reactor_class)
27
+ def calculate_backoff_delay(step_config)
28
28
  attempt_number = @context.retry_context.attempts_for_step(step_config.name)
29
- backoff_strategy = step_config.retry_config[:backoff] || reactor_class.retry_defaults[:backoff]
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, backoff_strategy, base_delay)
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, error, reactor_class)
36
+ def requeue_job_for_step_retry(step_config)
38
37
  @context.current_step = step_config.name
39
- delay = calculate_backoff_delay(step_config, error, reactor_class)
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, reactor_class, result)
149
+ handle_async_retry(step_config)
151
150
  else
152
- handle_sync_retry(step_config, reactor_class, result)
151
+ handle_sync_retry(step_config)
153
152
  end
154
153
  end
155
154
 
156
- def handle_async_retry(step_config, reactor_class, result)
157
- requeue_result = requeue_job_for_step_retry(step_config, result.error, reactor_class)
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, reactor_class, result)
173
- delay = calculate_backoff_delay(step_config, result.error, reactor_class)
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
- step_config_orig.run_block
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[:async_reactor_class]
58
- child_inputs = build_child_inputs(inputs[:argument_mappings] || {})
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[:composed_reactor_class], child_context, composed_data)
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[:composed_reactor_class], {}, child_context)
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[:argument_mappings] || {})
63
- child_context = RubyReactor::Context.new(composed_inputs, inputs[:composed_reactor_class])
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[:source].nil?
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[:fan_out]
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[:collect_block], inputs[:fail_fast])
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[:fail_fast].nil? || inputs[:fail_fast]
102
+ fail_fast = inputs.fail_fast.nil? || inputs.fail_fast
93
103
 
94
- inputs[:source].each_with_index do |element, index|
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[:argument_mappings] || {}, context, element)
114
- child_context = RubyReactor::Context.new(mapped_inputs, inputs[:mapped_reactor_class])
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[:mapped_reactor_class].name
144
+ element_reactor_class: inputs.mapped_reactor_class.name
135
145
  }
136
146
 
137
- executor = RubyReactor::Executor.new(inputs[:mapped_reactor_class], {}, child_context)
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[:source].size)
175
+ prepare_async_execution(map_id, inputs.source.size)
166
176
 
167
- reactor_class_info = build_reactor_class_info(inputs[:mapped_reactor_class], step_name)
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[:mapped_reactor_class].name
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[:source].size, context.reactor_class.name,
192
- strict_ordering: inputs[:strict_ordering], reactor_class_info: reactor_class_info,
193
- **map_recovery_metadata(inputs[:step_name] || context.current_step)
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[:batch_size] || inputs[:source].size
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[:source],
234
+ source: inputs.source,
225
235
  batch_size: batch_size,
226
236
  step_name: step_name,
227
- argument_mappings: inputs[:argument_mappings],
228
- strict_ordering: inputs[:strict_ordering],
229
- mapped_reactor_class: inputs[:mapped_reactor_class],
230
- fail_fast: inputs[:fail_fast].nil? || inputs[:fail_fast]
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[:strict_ordering])
242
+ queue_collector(map_id, step_name, inputs.strict_ordering)
233
243
  "map:#{map_id}"
234
244
  end
235
245
 
@@ -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[:amount])
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 one `extend` is
34
- # `Dsl::Lockable::ClassMethods` (the five coordination macros), a
35
- # self-contained module with no hooks of its own (Finding 9).
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
- @inputs = inputs
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- VERSION = "0.8.3"
4
+ VERSION = "0.8.4"
5
5
  end