ruby_reactor 0.7.1 → 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 (61) 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 +140 -0
  8. data/CLAUDE.md +1 -1
  9. data/README.md +125 -31
  10. data/lib/ruby_reactor/context.rb +2 -2
  11. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  12. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  13. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  14. data/lib/ruby_reactor/dsl/template_helpers.rb +2 -2
  15. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  16. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  17. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  18. data/lib/ruby_reactor/executor/result_handler.rb +9 -3
  19. data/lib/ruby_reactor/executor/retry_manager.rb +2 -1
  20. data/lib/ruby_reactor/executor/step_executor.rb +9 -2
  21. data/lib/ruby_reactor/executor.rb +3 -0
  22. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  23. data/lib/ruby_reactor/reactor.rb +9 -12
  24. data/lib/ruby_reactor/rspec/matchers.rb +3 -6
  25. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  26. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  27. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  28. data/lib/ruby_reactor/step/map_step.rb +177 -218
  29. data/lib/ruby_reactor/step.rb +116 -21
  30. data/lib/ruby_reactor/step_signals.rb +6 -2
  31. data/lib/ruby_reactor/step_worker.rb +25 -10
  32. data/lib/ruby_reactor/template/result.rb +9 -2
  33. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  34. data/lib/ruby_reactor/version.rb +1 -1
  35. data/lib/ruby_reactor.rb +5 -2
  36. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  37. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  38. data/specs/002-step-input-contracts/data-model.md +115 -0
  39. data/specs/002-step-input-contracts/plan.md +165 -0
  40. data/specs/002-step-input-contracts/quickstart.md +170 -0
  41. data/specs/002-step-input-contracts/research.md +233 -0
  42. data/specs/002-step-input-contracts/spec.md +359 -0
  43. data/specs/002-step-input-contracts/tasks.md +367 -0
  44. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  45. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  46. data/specs/004-inheritable-step-class/data-model.md +116 -0
  47. data/specs/004-inheritable-step-class/plan.md +174 -0
  48. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  49. data/specs/004-inheritable-step-class/research.md +308 -0
  50. data/specs/004-inheritable-step-class/spec.md +316 -0
  51. data/specs/004-inheritable-step-class/tasks.md +258 -0
  52. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  53. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  54. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  55. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  56. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  57. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  58. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  59. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  60. data/specs/possible_feature.md +22 -0
  61. 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 should_run_async?
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,203 @@ 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
+ def should_run_async?
73
+ return false if context.inline_async_execution
71
74
 
72
- def execute_inline_map(arguments, context)
73
- results = []
74
- fail_fast = arguments[:fail_fast].nil? || arguments[:fail_fast]
75
+ inputs[:async]
76
+ end
75
77
 
76
- arguments[:source].each do |element|
77
- result = execute_single_element(element, arguments, context)
78
+ def run_inline
79
+ results = execute_inline_map
80
+ return results if results.is_a?(RubyReactor::Failure) || results.is_a?(RubyReactor::Halt)
78
81
 
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)
82
+ process_results(results, inputs[:collect_block], inputs[:fail_fast])
83
+ end
82
84
 
83
- if fail_fast && result.failure?
84
- return result # Stop immediately on first failure
85
- end
85
+ def execute_inline_map
86
+ results = []
87
+ fail_fast = inputs[:fail_fast].nil? || inputs[:fail_fast]
86
88
 
87
- # When fail_fast is false, store Result objects; when true, store values
88
- results << (fail_fast ? result.value : result)
89
- end
89
+ inputs[:source].each do |element|
90
+ result = execute_single_element(element)
90
91
 
91
- results
92
- end
92
+ # An element-level Halt propagates as a run halt: stop immediately
93
+ # rather than being collected as a (nil) value.
94
+ return result if result.is_a?(RubyReactor::Halt)
93
95
 
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
96
+ if fail_fast && result.failure?
97
+ return result # Stop immediately on first failure
98
+ end
123
99
 
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
100
+ # When fail_fast is false, store Result objects; when true, store values
101
+ results << (fail_fast ? result.value : result)
128
102
  end
129
103
 
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
104
+ results
105
+ end
139
106
 
140
- # Simplified: both branches returned Success(results)
141
- RubyReactor::Success(results)
142
- end
107
+ def execute_single_element(element)
108
+ mapped_inputs = self.class.build_mapped_inputs(inputs[:argument_mappings] || {}, context, element)
109
+ child_context = RubyReactor::Context.new(mapped_inputs, inputs[:mapped_reactor_class])
110
+
111
+ link_contexts(child_context, context)
112
+
113
+ map_id = "#{context.context_id}:#{context.current_step}"
114
+ storage = RubyReactor.configuration.storage_adapter
115
+ storage.store_map_element_context_id(map_id, child_context.context_id, context.reactor_class.name)
116
+
117
+ # Set map metadata for failure handling
118
+ child_context.map_metadata = {
119
+ map_id: map_id,
120
+ parent_reactor_class_name: context.reactor_class.name,
121
+ index: nil # Inline map execution doesn't track index in metadata currently, but could
122
+ }
123
+
124
+ # Store reference in composed_contexts so the UI knows where to find elements
125
+ context.composed_contexts[context.current_step] = {
126
+ name: context.current_step,
127
+ type: :map_ref,
128
+ map_id: map_id,
129
+ element_reactor_class: inputs[:mapped_reactor_class].name
130
+ }
131
+
132
+ executor = RubyReactor::Executor.new(inputs[:mapped_reactor_class], {}, child_context)
133
+ executor.execute
134
+ executor.result
135
+ end
143
136
 
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)
137
+ def link_contexts(child_context, parent_context)
138
+ child_context.parent_context = parent_context
139
+ child_context.root_context = parent_context.root_context || parent_context
140
+ child_context.inline_async_execution = parent_context.inline_async_execution
141
+ end
142
+
143
+ def process_results(results, collect_block, _fail_fast = true)
144
+ if collect_block
145
+ begin
146
+ # Collect block receives Result objects when fail_fast is false, values when true
147
+ return RubyReactor::Success(collect_block.call(results))
148
+ rescue StandardError => e
149
+ return RubyReactor::Failure(e)
153
150
  end
154
151
  end
155
152
 
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)
153
+ # Simplified: both branches returned Success(results)
154
+ RubyReactor::Success(results)
155
+ end
162
156
 
163
- initialize_map_metadata(map_id, arguments, context, reactor_class_info)
157
+ def run_async(step_name)
158
+ map_id = "#{context.context_id}:#{step_name}"
159
+ context.map_operations[step_name.to_s] = map_id
160
+ prepare_async_execution(map_id, inputs[:source].size)
164
161
 
165
- job_id = dispatch_async_map(map_id, arguments, context, reactor_class_info, step_name)
162
+ reactor_class_info = build_reactor_class_info(inputs[:mapped_reactor_class], step_name)
166
163
 
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
- }
164
+ initialize_map_metadata(map_id, reactor_class_info)
174
165
 
175
- RubyReactor::DispatchResult.new(
176
- job_id: job_id,
177
- intermediate_results: context.intermediate_results,
178
- execution_id: context.context_id
179
- )
180
- end
166
+ job_id = dispatch_async_map(map_id, reactor_class_info, step_name)
181
167
 
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
168
+ # Store reference in composed_contexts so the UI knows where to find elements
169
+ context.composed_contexts[step_name.to_s] = {
170
+ name: step_name.to_s,
171
+ type: :map_ref,
172
+ map_id: map_id,
173
+ element_reactor_class: inputs[:mapped_reactor_class].name
174
+ }
190
175
 
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
176
+ RubyReactor::DispatchResult.new(
177
+ job_id: job_id,
178
+ intermediate_results: context.intermediate_results,
179
+ execution_id: context.context_id
180
+ )
181
+ end
204
182
 
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
183
+ def initialize_map_metadata(map_id, reactor_class_info)
184
+ storage = RubyReactor.configuration.storage_adapter
185
+ storage.initialize_map_operation(
186
+ map_id, inputs[:source].size, context.reactor_class.name,
187
+ strict_ordering: inputs[:strict_ordering], reactor_class_info: reactor_class_info,
188
+ **map_recovery_metadata(inputs[:step_name] || context.current_step)
189
+ )
190
+ end
229
191
 
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
192
+ # Recovery metadata for the map sweeper. When this map runs inside a map
193
+ # element (context.map_metadata present), it is a NESTED map: its parent
194
+ # holds the element's `map_element:` lock, not an `async:` lock (N1).
195
+ def map_recovery_metadata(step_name)
196
+ outer = context.map_metadata
197
+ {
198
+ parent_context_id: context.context_id,
199
+ step_name: step_name.to_s,
200
+ parent_is_map_element: !outer.nil?,
201
+ outer_map_id: outer && (outer[:map_id] || outer["map_id"]),
202
+ outer_index: outer && (outer[:index] || outer["index"])
203
+ }
204
+ end
238
205
 
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
206
+ def dispatch_async_map(map_id, _reactor_class_info, step_name)
207
+ # Every async map runs through the per-element Dispatcher path. When no
208
+ # batch_size is given we default to the full source size (one fan-out
209
+ # batch), so there is a single execution path: each element runs in its
210
+ # own worker, with the map counter/collector tracking completion. This
211
+ # lets elements with async steps or async retries hand off correctly
212
+ # instead of being forced to run synchronously in a single worker.
213
+ batch_size = inputs[:batch_size] || inputs[:source].size
214
+
215
+ RubyReactor::Map::Dispatcher.perform(
216
+ map_id: map_id,
217
+ parent_context_id: context.context_id,
218
+ parent_reactor_class_name: context.reactor_class.name,
219
+ source: inputs[:source],
220
+ batch_size: batch_size,
221
+ step_name: step_name,
222
+ argument_mappings: inputs[:argument_mappings],
223
+ strict_ordering: inputs[:strict_ordering],
224
+ mapped_reactor_class: inputs[:mapped_reactor_class],
225
+ fail_fast: inputs[:fail_fast].nil? || inputs[:fail_fast]
226
+ )
227
+ queue_collector(map_id, step_name, inputs[:strict_ordering])
228
+ "map:#{map_id}"
229
+ end
246
230
 
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
231
+ def prepare_async_execution(map_id, count)
232
+ storage = RubyReactor.configuration.storage_adapter
233
+ middlewares = context.middlewares || Executor.middlewares_for(context.reactor_class)
234
+ middlewares.on(:before_async_enqueue, context)
235
+ serialized_context = ContextSerializer.serialize(context)
236
+ storage.store_context(context.context_id, serialized_context, context.reactor_class.name)
237
+ storage.set_map_counter(map_id, count, context.reactor_class.name)
238
+ end
268
239
 
269
- queue_collector(map_id, context, step_name, arguments[:strict_ordering])
270
- first_job_id
240
+ def build_reactor_class_info(mapped_reactor_class, step_name)
241
+ if mapped_reactor_class.respond_to?(:name)
242
+ { "type" => "class", "name" => mapped_reactor_class.name }
243
+ else
244
+ { "type" => "inline", "parent" => context.reactor_class.name, "step" => step_name.to_s }
271
245
  end
246
+ end
272
247
 
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
248
+ def queue_collector(map_id, step_name, strict_ordering)
249
+ RubyReactor.configuration.async_router.perform_map_collection_async(
250
+ parent_context_id: context.context_id, map_id: map_id,
251
+ parent_reactor_class_name: context.reactor_class.name, step_name: step_name.to_s,
252
+ strict_ordering: strict_ordering, timeout: 3600
253
+ )
295
254
  end
296
255
  end
297
256
  end