ruby_reactor 0.7.1 → 0.8.1

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.specify/feature.json +1 -1
  4. data/.specify/memory/constitution.md +26 -16
  5. data/.specify/templates/plan-template.md +4 -0
  6. data/.specify/templates/tasks-template.md +1 -1
  7. data/CHANGELOG.md +150 -3
  8. data/CLAUDE.md +1 -1
  9. data/README.md +171 -63
  10. data/lib/ruby_reactor/adapters/active_job/router.rb +2 -2
  11. data/lib/ruby_reactor/adapters/sidekiq/router.rb +2 -2
  12. data/lib/ruby_reactor/context.rb +2 -2
  13. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  14. data/lib/ruby_reactor/dsl/map_builder.rb +31 -5
  15. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  16. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  17. data/lib/ruby_reactor/dsl/template_helpers.rb +2 -2
  18. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  19. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  20. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  21. data/lib/ruby_reactor/executor/result_handler.rb +9 -3
  22. data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
  23. data/lib/ruby_reactor/executor/step_executor.rb +9 -2
  24. data/lib/ruby_reactor/executor.rb +3 -0
  25. data/lib/ruby_reactor/map/helpers.rb +9 -0
  26. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  27. data/lib/ruby_reactor/reactor.rb +9 -12
  28. data/lib/ruby_reactor/rspec/matchers.rb +3 -6
  29. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  30. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  31. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  32. data/lib/ruby_reactor/step/map_step.rb +182 -218
  33. data/lib/ruby_reactor/step.rb +116 -21
  34. data/lib/ruby_reactor/step_signals.rb +6 -2
  35. data/lib/ruby_reactor/step_worker.rb +25 -10
  36. data/lib/ruby_reactor/template/result.rb +9 -2
  37. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  38. data/lib/ruby_reactor/version.rb +1 -1
  39. data/lib/ruby_reactor.rb +5 -2
  40. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  41. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  42. data/specs/002-step-input-contracts/data-model.md +115 -0
  43. data/specs/002-step-input-contracts/plan.md +165 -0
  44. data/specs/002-step-input-contracts/quickstart.md +170 -0
  45. data/specs/002-step-input-contracts/research.md +233 -0
  46. data/specs/002-step-input-contracts/spec.md +359 -0
  47. data/specs/002-step-input-contracts/tasks.md +367 -0
  48. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  49. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  50. data/specs/004-inheritable-step-class/data-model.md +116 -0
  51. data/specs/004-inheritable-step-class/plan.md +174 -0
  52. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  53. data/specs/004-inheritable-step-class/research.md +308 -0
  54. data/specs/004-inheritable-step-class/spec.md +316 -0
  55. data/specs/004-inheritable-step-class/tasks.md +258 -0
  56. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  57. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  58. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  59. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  60. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  61. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  62. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  63. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  64. data/specs/possible_feature.md +22 -0
  65. metadata +28 -1
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Step
5
+ # The inputs one unit of work accepts: declared with `input` on a step
6
+ # class, or inside an inline step's `inputs do ... end` block. Both forms
7
+ # are enforced by the same `#enforce!`, so they cannot drift apart.
8
+ class InputContract
9
+ include RubyReactor::Dsl::ValidationHelpers
10
+
11
+ Declaration = Struct.new(:name, :type, :optional, :default, :redact, :predicates, :macro_block, :schema,
12
+ :validator, keyword_init: true)
13
+
14
+ REDACTED = "[REDACTED]"
15
+
16
+ attr_reader :owner, :declarations, :cross_field_validators
17
+
18
+ def initialize(owner: nil, declarations: {}, cross_field_validators: [])
19
+ @owner = owner
20
+ @declarations = declarations
21
+ @cross_field_validators = cross_field_validators
22
+ end
23
+
24
+ # rubocop:disable Metrics/ParameterLists
25
+ def input(name, type = nil, optional: false, default: nil, redact: false, validate: nil, **predicates, &block)
26
+ # rubocop:enable Metrics/ParameterLists
27
+ check_dry_validation_available!
28
+ unless default.nil? || optional
29
+ raise Error::ValidationError,
30
+ "input :#{name} declares `default:` but is required; a default only applies to an " \
31
+ "optional input, so add `optional: true`."
32
+ end
33
+
34
+ @declarations[name] = Declaration.new(
35
+ name: name, type: type, optional: optional, default: default, redact: redact,
36
+ predicates: predicates, macro_block: block, schema: validate,
37
+ validator: build_declaration_validator(name, type, optional, validate, predicates, &block)
38
+ )
39
+ end
40
+
41
+ # Cross-field rules over the whole argument hash, applied after the
42
+ # per-input rules. A block, a dry-schema, or a dry-validation contract.
43
+ def validate_inputs(schema = nil, &block)
44
+ @cross_field_validators << create_input_validator(block || Validation::SchemaBuilder.schema_for(schema))
45
+ end
46
+
47
+ def declares?(name)
48
+ @declarations.key?(name.to_sym)
49
+ end
50
+
51
+ def required_names
52
+ @declarations.values.reject(&:optional).map(&:name)
53
+ end
54
+
55
+ def optional_names
56
+ @declarations.values.select(&:optional).map(&:name)
57
+ end
58
+
59
+ def defaults
60
+ @declarations.values.reject { |d| d.default.nil? }.to_h { |d| [d.name, d.default] }
61
+ end
62
+
63
+ def redacted_names
64
+ @declarations.values.select(&:redact).map(&:name)
65
+ end
66
+
67
+ def empty?
68
+ @declarations.empty? && @cross_field_validators.empty?
69
+ end
70
+
71
+ # A child's contract: the parent's declarations plus the child's, where a
72
+ # same-named child input replaces the parent's. Neither side is mutated.
73
+ def merge(child)
74
+ self.class.new(
75
+ owner: child.owner,
76
+ declarations: @declarations.merge(child.declarations),
77
+ cross_field_validators: @cross_field_validators + child.cross_field_validators
78
+ )
79
+ end
80
+
81
+ def redact(args)
82
+ names = redacted_names
83
+ return args if names.empty?
84
+
85
+ args.to_h { |key, value| [key, names.include?(key.to_sym) ? REDACTED : value] }
86
+ end
87
+
88
+ # Returns `args` with defaults applied — the resolved values, never the
89
+ # schema's coerced output — or raises InputValidationError. `step_name`
90
+ # is left for the caller, which knows it.
91
+ def enforce!(args)
92
+ args = apply_defaults(args)
93
+ errors = {}
94
+
95
+ @declarations.each_value do |declaration|
96
+ next unless declaration.validator
97
+ next if declaration.optional && !args.key?(declaration.name)
98
+
99
+ # `slice`, so an absent key reads "is missing" and nil "must be filled".
100
+ collect_errors(errors, declaration.validator.call(args.slice(declaration.name)))
101
+ end
102
+ @cross_field_validators.each { |validator| collect_errors(errors, validator.call(args)) }
103
+
104
+ return args if errors.empty?
105
+
106
+ error = Error::InputValidationError.new(errors)
107
+ error.step_arguments = redact(args)
108
+ raise error
109
+ end
110
+
111
+ # Applied when the key is absent or the value is nil — never for `false`.
112
+ # Public so a step's undo/compensate resolve the same `inputs` as `run`
113
+ # without validating them.
114
+ def apply_defaults(args)
115
+ defaults = self.defaults
116
+ return args if defaults.empty?
117
+
118
+ args.merge(defaults) { |_name, supplied, default| supplied.nil? ? default : supplied }
119
+ end
120
+
121
+ private
122
+
123
+ def collect_errors(errors, result)
124
+ errors.merge!(result.error.field_errors) if result.failure?
125
+ end
126
+ end
127
+ end
128
+ end
@@ -1,31 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyReactor
4
- module Step
5
- class MapStep
6
- include RubyReactor::Step
7
-
8
- def self.run(arguments, context)
9
- return RubyReactor::Failure("Map source cannot be nil") if arguments[:source].nil?
4
+ class Step
5
+ class MapStep < RubyReactor::Step
6
+ def run
7
+ return RubyReactor::Failure("Map source cannot be nil") if inputs[:source].nil?
10
8
 
11
9
  # Initialize map state in context if not present
12
10
  context.map_operations ||= {}
13
11
 
14
- if should_run_async?(arguments, context)
15
- run_async(arguments, context, context.current_step)
12
+ if fan_out?
13
+ run_async(context.current_step)
16
14
  else
17
- run_inline(arguments, context)
15
+ run_inline
18
16
  end
19
17
  end
20
18
 
21
- def self.compensate(_reason, _arguments, _context)
19
+ def compensate
22
20
  # TODO: Implement compensation for map steps
23
21
  RubyReactor.Success()
24
22
  end
25
23
 
26
24
  class << self
27
25
  def build_mapped_inputs(mappings, context, element)
28
- inputs = {}
26
+ built = {}
29
27
 
30
28
  mappings.each do |mapped_input_name, source|
31
29
  # Handle serialized template objects (Hashes from Sidekiq)
@@ -39,10 +37,10 @@ module RubyReactor
39
37
  else
40
38
  source.resolve(context)
41
39
  end
42
- inputs[mapped_input_name] = value
40
+ built[mapped_input_name] = value
43
41
  end
44
42
 
45
- inputs
43
+ built
46
44
  end
47
45
 
48
46
  def resolve_element(template_element, current_element)
@@ -56,242 +54,208 @@ module RubyReactor
56
54
 
57
55
  private
58
56
 
59
- def should_run_async?(arguments, context)
60
- return false if context.inline_async_execution
61
-
62
- arguments[:async]
57
+ def extract_path(value, path)
58
+ if path.is_a?(Symbol) && value.respond_to?(:[])
59
+ value[path]
60
+ elsif path.is_a?(String)
61
+ path.split(".").reduce(value) { |v, key| v&.send(:[], key) }
62
+ elsif path.is_a?(Array)
63
+ path.reduce(value) { |v, key| v&.send(:[], key) }
64
+ elsif value.respond_to?(path)
65
+ value.send(path)
66
+ end
63
67
  end
68
+ end
64
69
 
65
- def run_inline(arguments, context)
66
- results = execute_inline_map(arguments, context)
67
- return results if results.is_a?(RubyReactor::Failure) || results.is_a?(RubyReactor::Halt)
70
+ private
68
71
 
69
- process_results(results, arguments[:collect_block], arguments[:fail_fast])
70
- end
72
+ # Fans out anywhere except inside a map element: an element's result and
73
+ # the map's completion counter are tracked by its own ElementExecutor job,
74
+ # so a nested hand-off there would escape that tracking. A reactor worker
75
+ # (`background`, collector resume, `async_reactor` child) fans out as the
76
+ # caller would — the collector resumes the reactor in a worker either way.
77
+ def fan_out?
78
+ return false if context.map_metadata || context.root_context&.map_metadata
71
79
 
72
- def execute_inline_map(arguments, context)
73
- results = []
74
- fail_fast = arguments[:fail_fast].nil? || arguments[:fail_fast]
80
+ inputs[:fan_out]
81
+ end
75
82
 
76
- arguments[:source].each do |element|
77
- result = execute_single_element(element, arguments, context)
83
+ def run_inline
84
+ results = execute_inline_map
85
+ return results if results.is_a?(RubyReactor::Failure) || results.is_a?(RubyReactor::Halt)
78
86
 
79
- # An element-level Halt propagates as a run halt: stop immediately
80
- # rather than being collected as a (nil) value.
81
- return result if result.is_a?(RubyReactor::Halt)
87
+ process_results(results, inputs[:collect_block], inputs[:fail_fast])
88
+ end
82
89
 
83
- if fail_fast && result.failure?
84
- return result # Stop immediately on first failure
85
- end
90
+ def execute_inline_map
91
+ results = []
92
+ fail_fast = inputs[:fail_fast].nil? || inputs[:fail_fast]
86
93
 
87
- # When fail_fast is false, store Result objects; when true, store values
88
- results << (fail_fast ? result.value : result)
89
- end
94
+ inputs[:source].each do |element|
95
+ result = execute_single_element(element)
90
96
 
91
- results
92
- end
97
+ # An element-level Halt propagates as a run halt: stop immediately
98
+ # rather than being collected as a (nil) value.
99
+ return result if result.is_a?(RubyReactor::Halt)
93
100
 
94
- def execute_single_element(element, arguments, context)
95
- mapped_inputs = build_mapped_inputs(arguments[:argument_mappings] || {}, context, element)
96
- child_context = RubyReactor::Context.new(mapped_inputs, arguments[:mapped_reactor_class])
97
-
98
- link_contexts(child_context, context)
99
-
100
- map_id = "#{context.context_id}:#{context.current_step}"
101
- storage = RubyReactor.configuration.storage_adapter
102
- storage.store_map_element_context_id(map_id, child_context.context_id, context.reactor_class.name)
103
-
104
- # Set map metadata for failure handling
105
- child_context.map_metadata = {
106
- map_id: map_id,
107
- parent_reactor_class_name: context.reactor_class.name,
108
- index: nil # Inline map execution doesn't track index in metadata currently, but could
109
- }
110
-
111
- # Store reference in composed_contexts so the UI knows where to find elements
112
- context.composed_contexts[context.current_step] = {
113
- name: context.current_step,
114
- type: :map_ref,
115
- map_id: map_id,
116
- element_reactor_class: arguments[:mapped_reactor_class].name
117
- }
118
-
119
- executor = RubyReactor::Executor.new(arguments[:mapped_reactor_class], {}, child_context)
120
- executor.execute
121
- executor.result
122
- end
101
+ if fail_fast && result.failure?
102
+ return result # Stop immediately on first failure
103
+ end
123
104
 
124
- def link_contexts(child_context, parent_context)
125
- child_context.parent_context = parent_context
126
- child_context.root_context = parent_context.root_context || parent_context
127
- child_context.inline_async_execution = parent_context.inline_async_execution
105
+ # When fail_fast is false, store Result objects; when true, store values
106
+ results << (fail_fast ? result.value : result)
128
107
  end
129
108
 
130
- def process_results(results, collect_block, _fail_fast = true)
131
- if collect_block
132
- begin
133
- # Collect block receives Result objects when fail_fast is false, values when true
134
- return RubyReactor::Success(collect_block.call(results))
135
- rescue StandardError => e
136
- return RubyReactor::Failure(e)
137
- end
138
- end
109
+ results
110
+ end
139
111
 
140
- # Simplified: both branches returned Success(results)
141
- RubyReactor::Success(results)
142
- end
112
+ def execute_single_element(element)
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])
115
+
116
+ link_contexts(child_context, context)
117
+
118
+ map_id = "#{context.context_id}:#{context.current_step}"
119
+ storage = RubyReactor.configuration.storage_adapter
120
+ storage.store_map_element_context_id(map_id, child_context.context_id, context.reactor_class.name)
121
+
122
+ # Set map metadata for failure handling
123
+ child_context.map_metadata = {
124
+ map_id: map_id,
125
+ parent_reactor_class_name: context.reactor_class.name,
126
+ index: nil # Inline map execution doesn't track index in metadata currently, but could
127
+ }
128
+
129
+ # Store reference in composed_contexts so the UI knows where to find elements
130
+ context.composed_contexts[context.current_step] = {
131
+ name: context.current_step,
132
+ type: :map_ref,
133
+ map_id: map_id,
134
+ element_reactor_class: inputs[:mapped_reactor_class].name
135
+ }
136
+
137
+ executor = RubyReactor::Executor.new(inputs[:mapped_reactor_class], {}, child_context)
138
+ executor.execute
139
+ executor.result
140
+ end
143
141
 
144
- def extract_path(value, path)
145
- if path.is_a?(Symbol) && value.respond_to?(:[])
146
- value[path]
147
- elsif path.is_a?(String)
148
- path.split(".").reduce(value) { |v, key| v&.send(:[], key) }
149
- elsif path.is_a?(Array)
150
- path.reduce(value) { |v, key| v&.send(:[], key) }
151
- elsif value.respond_to?(path)
152
- value.send(path)
142
+ def link_contexts(child_context, parent_context)
143
+ child_context.parent_context = parent_context
144
+ child_context.root_context = parent_context.root_context || parent_context
145
+ child_context.inline_async_execution = parent_context.inline_async_execution
146
+ end
147
+
148
+ def process_results(results, collect_block, _fail_fast = true)
149
+ if collect_block
150
+ begin
151
+ # Collect block receives Result objects when fail_fast is false, values when true
152
+ return RubyReactor::Success(collect_block.call(results))
153
+ rescue StandardError => e
154
+ return RubyReactor::Failure(e)
153
155
  end
154
156
  end
155
157
 
156
- def run_async(arguments, context, step_name)
157
- map_id = "#{context.context_id}:#{step_name}"
158
- context.map_operations[step_name.to_s] = map_id
159
- prepare_async_execution(context, map_id, arguments[:source].size)
160
-
161
- reactor_class_info = build_reactor_class_info(arguments[:mapped_reactor_class], context, step_name)
158
+ # Simplified: both branches returned Success(results)
159
+ RubyReactor::Success(results)
160
+ end
162
161
 
163
- initialize_map_metadata(map_id, arguments, context, reactor_class_info)
162
+ def run_async(step_name)
163
+ map_id = "#{context.context_id}:#{step_name}"
164
+ context.map_operations[step_name.to_s] = map_id
165
+ prepare_async_execution(map_id, inputs[:source].size)
164
166
 
165
- job_id = dispatch_async_map(map_id, arguments, context, reactor_class_info, step_name)
167
+ reactor_class_info = build_reactor_class_info(inputs[:mapped_reactor_class], step_name)
166
168
 
167
- # Store reference in composed_contexts so the UI knows where to find elements
168
- context.composed_contexts[step_name.to_s] = {
169
- name: step_name.to_s,
170
- type: :map_ref,
171
- map_id: map_id,
172
- element_reactor_class: arguments[:mapped_reactor_class].name
173
- }
169
+ initialize_map_metadata(map_id, reactor_class_info)
174
170
 
175
- RubyReactor::DispatchResult.new(
176
- job_id: job_id,
177
- intermediate_results: context.intermediate_results,
178
- execution_id: context.context_id
179
- )
180
- end
171
+ job_id = dispatch_async_map(map_id, reactor_class_info, step_name)
181
172
 
182
- def initialize_map_metadata(map_id, arguments, context, reactor_class_info)
183
- storage = RubyReactor.configuration.storage_adapter
184
- storage.initialize_map_operation(
185
- map_id, arguments[:source].size, context.reactor_class.name,
186
- strict_ordering: arguments[:strict_ordering], reactor_class_info: reactor_class_info,
187
- **map_recovery_metadata(context, arguments[:step_name] || context.current_step)
188
- )
189
- end
173
+ # Store reference in composed_contexts so the UI knows where to find elements
174
+ context.composed_contexts[step_name.to_s] = {
175
+ name: step_name.to_s,
176
+ type: :map_ref,
177
+ map_id: map_id,
178
+ element_reactor_class: inputs[:mapped_reactor_class].name
179
+ }
190
180
 
191
- # Recovery metadata for the map sweeper. When this map runs inside a map
192
- # element (context.map_metadata present), it is a NESTED map: its parent
193
- # holds the element's `map_element:` lock, not an `async:` lock (N1).
194
- def map_recovery_metadata(context, step_name)
195
- outer = context.map_metadata
196
- {
197
- parent_context_id: context.context_id,
198
- step_name: step_name.to_s,
199
- parent_is_map_element: !outer.nil?,
200
- outer_map_id: outer && (outer[:map_id] || outer["map_id"]),
201
- outer_index: outer && (outer[:index] || outer["index"])
202
- }
203
- end
181
+ RubyReactor::DispatchResult.new(
182
+ job_id: job_id,
183
+ intermediate_results: context.intermediate_results,
184
+ execution_id: context.context_id
185
+ )
186
+ end
204
187
 
205
- def dispatch_async_map(map_id, arguments, context, _reactor_class_info, step_name)
206
- # Every async map runs through the per-element Dispatcher path. When no
207
- # batch_size is given we default to the full source size (one fan-out
208
- # batch), so there is a single execution path: each element runs in its
209
- # own worker, with the map counter/collector tracking completion. This
210
- # lets elements with async steps or async retries hand off correctly
211
- # instead of being forced to run synchronously in a single worker.
212
- batch_size = arguments[:batch_size] || arguments[:source].size
213
-
214
- RubyReactor::Map::Dispatcher.perform(
215
- map_id: map_id,
216
- parent_context_id: context.context_id,
217
- parent_reactor_class_name: context.reactor_class.name,
218
- source: arguments[:source],
219
- batch_size: batch_size,
220
- step_name: step_name,
221
- argument_mappings: arguments[:argument_mappings],
222
- strict_ordering: arguments[:strict_ordering],
223
- mapped_reactor_class: arguments[:mapped_reactor_class],
224
- fail_fast: arguments[:fail_fast].nil? || arguments[:fail_fast]
225
- )
226
- queue_collector(map_id, context, step_name, arguments[:strict_ordering])
227
- "map:#{map_id}"
228
- end
188
+ def initialize_map_metadata(map_id, reactor_class_info)
189
+ storage = RubyReactor.configuration.storage_adapter
190
+ 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)
194
+ )
195
+ end
229
196
 
230
- def prepare_async_execution(context, map_id, count)
231
- storage = RubyReactor.configuration.storage_adapter
232
- middlewares = context.middlewares || Executor.middlewares_for(context.reactor_class)
233
- middlewares.on(:before_async_enqueue, context)
234
- serialized_context = ContextSerializer.serialize(context)
235
- storage.store_context(context.context_id, serialized_context, context.reactor_class.name)
236
- storage.set_map_counter(map_id, count, context.reactor_class.name)
237
- end
197
+ # Recovery metadata for the map sweeper. When this map runs inside a map
198
+ # element (context.map_metadata present), it is a NESTED map: its parent
199
+ # holds the element's `map_element:` lock, not an `async:` lock (N1).
200
+ def map_recovery_metadata(step_name)
201
+ outer = context.map_metadata
202
+ {
203
+ parent_context_id: context.context_id,
204
+ step_name: step_name.to_s,
205
+ parent_is_map_element: !outer.nil?,
206
+ outer_map_id: outer && (outer[:map_id] || outer["map_id"]),
207
+ outer_index: outer && (outer[:index] || outer["index"])
208
+ }
209
+ end
238
210
 
239
- def build_reactor_class_info(mapped_reactor_class, context, step_name)
240
- if mapped_reactor_class.respond_to?(:name)
241
- { "type" => "class", "name" => mapped_reactor_class.name }
242
- else
243
- { "type" => "inline", "parent" => context.reactor_class.name, "step" => step_name.to_s }
244
- end
245
- end
211
+ def dispatch_async_map(map_id, _reactor_class_info, step_name)
212
+ # Every async map runs through the per-element Dispatcher path. When no
213
+ # batch_size is given we default to the full source size (one fan-out
214
+ # batch), so there is a single execution path: each element runs in its
215
+ # own worker, with the map counter/collector tracking completion. This
216
+ # lets elements with async steps or async retries hand off correctly
217
+ # instead of being forced to run synchronously in a single worker.
218
+ batch_size = inputs[:batch_size] || inputs[:source].size
219
+
220
+ RubyReactor::Map::Dispatcher.perform(
221
+ map_id: map_id,
222
+ parent_context_id: context.context_id,
223
+ parent_reactor_class_name: context.reactor_class.name,
224
+ source: inputs[:source],
225
+ batch_size: batch_size,
226
+ 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]
231
+ )
232
+ queue_collector(map_id, step_name, inputs[:strict_ordering])
233
+ "map:#{map_id}"
234
+ end
246
235
 
247
- # rubocop:disable Metrics/ParameterLists
248
- def queue_fan_out(map_id:, arguments:, context:, reactor_class_info:, step_name:, limit: nil)
249
- # rubocop:enable Metrics/ParameterLists
250
- storage = RubyReactor.configuration.storage_adapter
251
- storage.initialize_map_operation(
252
- map_id, arguments[:source].size, context.reactor_class.name,
253
- strict_ordering: arguments[:strict_ordering], reactor_class_info: reactor_class_info,
254
- **map_recovery_metadata(context, step_name)
255
- )
256
-
257
- limit ||= arguments[:source].size
258
- first_job_id = nil
259
- arguments[:source].each_with_index do |element, index|
260
- break if index >= limit
261
-
262
- job_id = queue_map_element(
263
- map_id: map_id, element: element, index: index, arguments: arguments,
264
- context: context, reactor_class_info: reactor_class_info, step_name: step_name
265
- )
266
- first_job_id ||= job_id
267
- end
236
+ def prepare_async_execution(map_id, count)
237
+ storage = RubyReactor.configuration.storage_adapter
238
+ middlewares = context.middlewares || Executor.middlewares_for(context.reactor_class)
239
+ middlewares.on(:before_async_enqueue, context)
240
+ serialized_context = ContextSerializer.serialize(context)
241
+ storage.store_context(context.context_id, serialized_context, context.reactor_class.name)
242
+ storage.set_map_counter(map_id, count, context.reactor_class.name)
243
+ end
268
244
 
269
- queue_collector(map_id, context, step_name, arguments[:strict_ordering])
270
- first_job_id
245
+ def build_reactor_class_info(mapped_reactor_class, step_name)
246
+ if mapped_reactor_class.respond_to?(:name)
247
+ { "type" => "class", "name" => mapped_reactor_class.name }
248
+ else
249
+ { "type" => "inline", "parent" => context.reactor_class.name, "step" => step_name.to_s }
271
250
  end
251
+ end
272
252
 
273
- # rubocop:disable Metrics/ParameterLists
274
- def queue_map_element(map_id:, element:, index:, arguments:, context:, reactor_class_info:, step_name:)
275
- mapped_inputs = build_mapped_inputs(arguments[:argument_mappings] || {}, context, element)
276
- serialized_inputs = ContextSerializer.serialize_value(mapped_inputs)
277
-
278
- RubyReactor.configuration.async_router.perform_map_element_async(
279
- map_id: map_id, element_id: "#{map_id}:#{index}", index: index,
280
- serialized_inputs: serialized_inputs, reactor_class_info: reactor_class_info,
281
- strict_ordering: arguments[:strict_ordering], parent_context_id: context.context_id,
282
- parent_reactor_class_name: context.reactor_class.name, step_name: step_name.to_s,
283
- batch_size: arguments[:batch_size]
284
- )
285
- end
286
- # rubocop:enable Metrics/ParameterLists
287
-
288
- def queue_collector(map_id, context, step_name, strict_ordering)
289
- RubyReactor.configuration.async_router.perform_map_collection_async(
290
- parent_context_id: context.context_id, map_id: map_id,
291
- parent_reactor_class_name: context.reactor_class.name, step_name: step_name.to_s,
292
- strict_ordering: strict_ordering, timeout: 3600
293
- )
294
- end
253
+ def queue_collector(map_id, step_name, strict_ordering)
254
+ RubyReactor.configuration.async_router.perform_map_collection_async(
255
+ parent_context_id: context.context_id, map_id: map_id,
256
+ parent_reactor_class_name: context.reactor_class.name, step_name: step_name.to_s,
257
+ strict_ordering: strict_ordering, timeout: 3600
258
+ )
295
259
  end
296
260
  end
297
261
  end