ruby_reactor 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/demo-app-e2e-verify/SKILL.md +226 -0
  3. data/.claude/skills/speckit-demo-tests/SKILL.md +144 -0
  4. data/.release-please-manifest.json +1 -1
  5. data/.specify/feature.json +1 -1
  6. data/.specify/memory/constitution.md +92 -15
  7. data/.specify/templates/plan-template.md +4 -0
  8. data/.specify/templates/tasks-template.md +8 -1
  9. data/CHANGELOG.md +151 -0
  10. data/CLAUDE.md +2 -2
  11. data/README.md +149 -40
  12. data/lib/ruby_reactor/context.rb +9 -2
  13. data/lib/ruby_reactor/context_serializer.rb +13 -0
  14. data/lib/ruby_reactor/dsl/interrupt_builder.rb +6 -0
  15. data/lib/ruby_reactor/dsl/lockable.rb +2 -2
  16. data/lib/ruby_reactor/dsl/reactor.rb +36 -18
  17. data/lib/ruby_reactor/dsl/step_builder.rb +95 -2
  18. data/lib/ruby_reactor/dsl/template_helpers.rb +13 -5
  19. data/lib/ruby_reactor/dsl/validation_helpers.rb +17 -0
  20. data/lib/ruby_reactor/error/input_validation_error.rb +4 -0
  21. data/lib/ruby_reactor/error/step_failure_error.rb +10 -3
  22. data/lib/ruby_reactor/executor/compensation_manager.rb +58 -45
  23. data/lib/ruby_reactor/executor/ordered_lock_support.rb +9 -9
  24. data/lib/ruby_reactor/executor/result_handler.rb +44 -13
  25. data/lib/ruby_reactor/executor/retry_manager.rb +6 -2
  26. data/lib/ruby_reactor/executor/step_executor.rb +27 -14
  27. data/lib/ruby_reactor/executor.rb +20 -15
  28. data/lib/ruby_reactor/map/element_executor.rb +7 -1
  29. data/lib/ruby_reactor/map/helpers.rb +9 -7
  30. data/lib/ruby_reactor/map/result_enumerator.rb +2 -0
  31. data/lib/ruby_reactor/map/sweeper.rb +1 -1
  32. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +3 -2
  33. data/lib/ruby_reactor/open_telemetry.rb +7 -4
  34. data/lib/ruby_reactor/ordered_lock.rb +3 -3
  35. data/lib/ruby_reactor/reactor.rb +9 -12
  36. data/lib/ruby_reactor/rspec/matchers.rb +64 -17
  37. data/lib/ruby_reactor/rspec/test_subject.rb +8 -8
  38. data/lib/ruby_reactor/step/async_reactor_step.rb +159 -162
  39. data/lib/ruby_reactor/step/compose_step.rb +56 -75
  40. data/lib/ruby_reactor/step/input_contract.rb +128 -0
  41. data/lib/ruby_reactor/step/map_step.rb +178 -215
  42. data/lib/ruby_reactor/step.rb +119 -18
  43. data/lib/ruby_reactor/step_signals.rb +37 -0
  44. data/lib/ruby_reactor/step_worker.rb +25 -10
  45. data/lib/ruby_reactor/storage/adapter.rb +4 -0
  46. data/lib/ruby_reactor/storage/redis_adapter.rb +1 -72
  47. data/lib/ruby_reactor/storage/redis_reactor_scan.rb +116 -0
  48. data/lib/ruby_reactor/template/result.rb +9 -2
  49. data/lib/ruby_reactor/utils/fetch_indifferent.rb +13 -0
  50. data/lib/ruby_reactor/version.rb +1 -1
  51. data/lib/ruby_reactor/web/api.rb +23 -6
  52. data/lib/ruby_reactor/web/public/assets/index-BQvIWPdx.css +1 -0
  53. data/lib/ruby_reactor/web/public/assets/index-Dw4KV4QY.js +22 -0
  54. data/lib/ruby_reactor/web/public/index.html +2 -2
  55. data/lib/ruby_reactor.rb +61 -9
  56. data/specs/002-step-input-contracts/checklists/requirements.md +49 -0
  57. data/specs/002-step-input-contracts/contracts/dsl-surface.md +193 -0
  58. data/specs/002-step-input-contracts/data-model.md +115 -0
  59. data/specs/002-step-input-contracts/plan.md +165 -0
  60. data/specs/002-step-input-contracts/quickstart.md +170 -0
  61. data/specs/002-step-input-contracts/research.md +233 -0
  62. data/specs/002-step-input-contracts/spec.md +359 -0
  63. data/specs/002-step-input-contracts/tasks.md +367 -0
  64. data/specs/004-inheritable-step-class/checklists/requirements.md +40 -0
  65. data/specs/004-inheritable-step-class/contracts/step-lifecycle.md +85 -0
  66. data/specs/004-inheritable-step-class/data-model.md +116 -0
  67. data/specs/004-inheritable-step-class/plan.md +174 -0
  68. data/specs/004-inheritable-step-class/quickstart.md +112 -0
  69. data/specs/004-inheritable-step-class/research.md +308 -0
  70. data/specs/004-inheritable-step-class/spec.md +316 -0
  71. data/specs/004-inheritable-step-class/tasks.md +258 -0
  72. data/specs/deferred-003-step-lock-declarations/checklists/requirements.md +51 -0
  73. data/specs/deferred-003-step-lock-declarations/contracts/dsl-surface.md +154 -0
  74. data/specs/deferred-003-step-lock-declarations/data-model.md +131 -0
  75. data/specs/deferred-003-step-lock-declarations/plan.md +166 -0
  76. data/specs/deferred-003-step-lock-declarations/quickstart.md +169 -0
  77. data/specs/deferred-003-step-lock-declarations/research.md +196 -0
  78. data/specs/deferred-003-step-lock-declarations/spec.md +447 -0
  79. data/specs/deferred-003-step-lock-declarations/tasks.md +572 -0
  80. data/specs/possible_feature.md +22 -0
  81. metadata +34 -11
  82. data/lib/ruby_reactor/web/public/assets/index-B46p-M6K.css +0 -1
  83. data/lib/ruby_reactor/web/public/assets/index-DPmP4yXT.js +0 -22
  84. data/specs/001-background-async-steps/checklists/requirements.md +0 -39
  85. data/specs/001-background-async-steps/contracts/public-dsl.md +0 -154
  86. data/specs/001-background-async-steps/data-model.md +0 -117
  87. data/specs/001-background-async-steps/plan.md +0 -168
  88. data/specs/001-background-async-steps/quickstart.md +0 -102
  89. data/specs/001-background-async-steps/research.md +0 -150
  90. data/specs/001-background-async-steps/spec.md +0 -146
  91. data/specs/001-background-async-steps/tasks.md +0 -271
@@ -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,238 +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)
68
-
69
- process_results(results, arguments[:collect_block], arguments[:fail_fast])
70
- end
70
+ private
71
71
 
72
- def execute_inline_map(arguments, context)
73
- results = []
74
- fail_fast = arguments[:fail_fast].nil? || arguments[:fail_fast]
72
+ def should_run_async?
73
+ return false if context.inline_async_execution
75
74
 
76
- arguments[:source].each do |element|
77
- result = execute_single_element(element, arguments, context)
75
+ inputs[:async]
76
+ end
78
77
 
79
- if fail_fast && result.failure?
80
- return result # Stop immediately on first failure
81
- end
78
+ def run_inline
79
+ results = execute_inline_map
80
+ return results if results.is_a?(RubyReactor::Failure) || results.is_a?(RubyReactor::Halt)
82
81
 
83
- # When fail_fast is false, store Result objects; when true, store values
84
- results << (fail_fast ? result.value : result)
85
- end
82
+ process_results(results, inputs[:collect_block], inputs[:fail_fast])
83
+ end
86
84
 
87
- results
88
- end
85
+ def execute_inline_map
86
+ results = []
87
+ fail_fast = inputs[:fail_fast].nil? || inputs[:fail_fast]
89
88
 
90
- def execute_single_element(element, arguments, context)
91
- mapped_inputs = build_mapped_inputs(arguments[:argument_mappings] || {}, context, element)
92
- child_context = RubyReactor::Context.new(mapped_inputs, arguments[:mapped_reactor_class])
93
-
94
- link_contexts(child_context, context)
95
-
96
- map_id = "#{context.context_id}:#{context.current_step}"
97
- storage = RubyReactor.configuration.storage_adapter
98
- storage.store_map_element_context_id(map_id, child_context.context_id, context.reactor_class.name)
99
-
100
- # Set map metadata for failure handling
101
- child_context.map_metadata = {
102
- map_id: map_id,
103
- parent_reactor_class_name: context.reactor_class.name,
104
- index: nil # Inline map execution doesn't track index in metadata currently, but could
105
- }
106
-
107
- # Store reference in composed_contexts so the UI knows where to find elements
108
- context.composed_contexts[context.current_step] = {
109
- name: context.current_step,
110
- type: :map_ref,
111
- map_id: map_id,
112
- element_reactor_class: arguments[:mapped_reactor_class].name
113
- }
114
-
115
- executor = RubyReactor::Executor.new(arguments[:mapped_reactor_class], {}, child_context)
116
- executor.execute
117
- executor.result
118
- end
89
+ inputs[:source].each do |element|
90
+ result = execute_single_element(element)
119
91
 
120
- def link_contexts(child_context, parent_context)
121
- child_context.parent_context = parent_context
122
- child_context.root_context = parent_context.root_context || parent_context
123
- child_context.inline_async_execution = parent_context.inline_async_execution
124
- 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)
125
95
 
126
- def process_results(results, collect_block, _fail_fast = true)
127
- if collect_block
128
- begin
129
- # Collect block receives Result objects when fail_fast is false, values when true
130
- return RubyReactor::Success(collect_block.call(results))
131
- rescue StandardError => e
132
- return RubyReactor::Failure(e)
133
- end
96
+ if fail_fast && result.failure?
97
+ return result # Stop immediately on first failure
134
98
  end
135
99
 
136
- # Simplified: both branches returned Success(results)
137
- RubyReactor::Success(results)
100
+ # When fail_fast is false, store Result objects; when true, store values
101
+ results << (fail_fast ? result.value : result)
138
102
  end
139
103
 
140
- def extract_path(value, path)
141
- if path.is_a?(Symbol) && value.respond_to?(:[])
142
- value[path]
143
- elsif path.is_a?(String)
144
- path.split(".").reduce(value) { |v, key| v&.send(:[], key) }
145
- elsif path.is_a?(Array)
146
- path.reduce(value) { |v, key| v&.send(:[], key) }
147
- elsif value.respond_to?(path)
148
- value.send(path)
104
+ results
105
+ end
106
+
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
136
+
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)
149
150
  end
150
151
  end
151
152
 
152
- def run_async(arguments, context, step_name)
153
- map_id = "#{context.context_id}:#{step_name}"
154
- context.map_operations[step_name.to_s] = map_id
155
- prepare_async_execution(context, map_id, arguments[:source].size)
153
+ # Simplified: both branches returned Success(results)
154
+ RubyReactor::Success(results)
155
+ end
156
156
 
157
- reactor_class_info = build_reactor_class_info(arguments[:mapped_reactor_class], context, step_name)
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)
158
161
 
159
- initialize_map_metadata(map_id, arguments, context, reactor_class_info)
162
+ reactor_class_info = build_reactor_class_info(inputs[:mapped_reactor_class], step_name)
160
163
 
161
- job_id = dispatch_async_map(map_id, arguments, context, reactor_class_info, step_name)
164
+ initialize_map_metadata(map_id, reactor_class_info)
162
165
 
163
- # Store reference in composed_contexts so the UI knows where to find elements
164
- context.composed_contexts[step_name.to_s] = {
165
- name: step_name.to_s,
166
- type: :map_ref,
167
- map_id: map_id,
168
- element_reactor_class: arguments[:mapped_reactor_class].name
169
- }
166
+ job_id = dispatch_async_map(map_id, reactor_class_info, step_name)
170
167
 
171
- RubyReactor::DispatchResult.new(
172
- job_id: job_id,
173
- intermediate_results: context.intermediate_results,
174
- execution_id: context.context_id
175
- )
176
- 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
+ }
177
175
 
178
- def initialize_map_metadata(map_id, arguments, context, reactor_class_info)
179
- storage = RubyReactor.configuration.storage_adapter
180
- storage.initialize_map_operation(
181
- map_id, arguments[:source].size, context.reactor_class.name,
182
- strict_ordering: arguments[:strict_ordering], reactor_class_info: reactor_class_info,
183
- **map_recovery_metadata(context, arguments[:step_name] || context.current_step)
184
- )
185
- end
186
-
187
- # Recovery metadata for the map sweeper. When this map runs inside a map
188
- # element (context.map_metadata present), it is a NESTED map: its parent
189
- # holds the element's `map_element:` lock, not an `async:` lock (N1).
190
- def map_recovery_metadata(context, step_name)
191
- outer = context.map_metadata
192
- {
193
- parent_context_id: context.context_id,
194
- step_name: step_name.to_s,
195
- parent_is_map_element: !outer.nil?,
196
- outer_map_id: outer && (outer[:map_id] || outer["map_id"]),
197
- outer_index: outer && (outer[:index] || outer["index"])
198
- }
199
- 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
200
182
 
201
- def dispatch_async_map(map_id, arguments, context, _reactor_class_info, step_name)
202
- # Every async map runs through the per-element Dispatcher path. When no
203
- # batch_size is given we default to the full source size (one fan-out
204
- # batch), so there is a single execution path: each element runs in its
205
- # own worker, with the map counter/collector tracking completion. This
206
- # lets elements with async steps or async retries hand off correctly
207
- # instead of being forced to run synchronously in a single worker.
208
- batch_size = arguments[:batch_size] || arguments[:source].size
209
-
210
- RubyReactor::Map::Dispatcher.perform(
211
- map_id: map_id,
212
- parent_context_id: context.context_id,
213
- parent_reactor_class_name: context.reactor_class.name,
214
- source: arguments[:source],
215
- batch_size: batch_size,
216
- step_name: step_name,
217
- argument_mappings: arguments[:argument_mappings],
218
- strict_ordering: arguments[:strict_ordering],
219
- mapped_reactor_class: arguments[:mapped_reactor_class],
220
- fail_fast: arguments[:fail_fast].nil? || arguments[:fail_fast]
221
- )
222
- queue_collector(map_id, context, step_name, arguments[:strict_ordering])
223
- "map:#{map_id}"
224
- 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
225
191
 
226
- def prepare_async_execution(context, map_id, count)
227
- storage = RubyReactor.configuration.storage_adapter
228
- middlewares = context.middlewares || Executor.middlewares_for(context.reactor_class)
229
- middlewares.on(:before_async_enqueue, context)
230
- serialized_context = ContextSerializer.serialize(context)
231
- storage.store_context(context.context_id, serialized_context, context.reactor_class.name)
232
- storage.set_map_counter(map_id, count, context.reactor_class.name)
233
- 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
234
205
 
235
- def build_reactor_class_info(mapped_reactor_class, context, step_name)
236
- if mapped_reactor_class.respond_to?(:name)
237
- { "type" => "class", "name" => mapped_reactor_class.name }
238
- else
239
- { "type" => "inline", "parent" => context.reactor_class.name, "step" => step_name.to_s }
240
- end
241
- 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
242
230
 
243
- # rubocop:disable Metrics/ParameterLists
244
- def queue_fan_out(map_id:, arguments:, context:, reactor_class_info:, step_name:, limit: nil)
245
- # rubocop:enable Metrics/ParameterLists
246
- storage = RubyReactor.configuration.storage_adapter
247
- storage.initialize_map_operation(
248
- map_id, arguments[:source].size, context.reactor_class.name,
249
- strict_ordering: arguments[:strict_ordering], reactor_class_info: reactor_class_info,
250
- **map_recovery_metadata(context, step_name)
251
- )
252
-
253
- limit ||= arguments[:source].size
254
- first_job_id = nil
255
- arguments[:source].each_with_index do |element, index|
256
- break if index >= limit
257
-
258
- job_id = queue_map_element(
259
- map_id: map_id, element: element, index: index, arguments: arguments,
260
- context: context, reactor_class_info: reactor_class_info, step_name: step_name
261
- )
262
- first_job_id ||= job_id
263
- 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
264
239
 
265
- queue_collector(map_id, context, step_name, arguments[:strict_ordering])
266
- 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 }
267
245
  end
246
+ end
268
247
 
269
- # rubocop:disable Metrics/ParameterLists
270
- def queue_map_element(map_id:, element:, index:, arguments:, context:, reactor_class_info:, step_name:)
271
- mapped_inputs = build_mapped_inputs(arguments[:argument_mappings] || {}, context, element)
272
- serialized_inputs = ContextSerializer.serialize_value(mapped_inputs)
273
-
274
- RubyReactor.configuration.async_router.perform_map_element_async(
275
- map_id: map_id, element_id: "#{map_id}:#{index}", index: index,
276
- serialized_inputs: serialized_inputs, reactor_class_info: reactor_class_info,
277
- strict_ordering: arguments[:strict_ordering], parent_context_id: context.context_id,
278
- parent_reactor_class_name: context.reactor_class.name, step_name: step_name.to_s,
279
- batch_size: arguments[:batch_size]
280
- )
281
- end
282
- # rubocop:enable Metrics/ParameterLists
283
-
284
- def queue_collector(map_id, context, step_name, strict_ordering)
285
- RubyReactor.configuration.async_router.perform_map_collection_async(
286
- parent_context_id: context.context_id, map_id: map_id,
287
- parent_reactor_class_name: context.reactor_class.name, step_name: step_name.to_s,
288
- strict_ordering: strict_ordering, timeout: 3600
289
- )
290
- 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
+ )
291
254
  end
292
255
  end
293
256
  end