ruby_reactor 0.1.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 (75) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +98 -0
  4. data/CODE_OF_CONDUCT.md +84 -0
  5. data/README.md +570 -0
  6. data/Rakefile +12 -0
  7. data/documentation/DAG.md +457 -0
  8. data/documentation/README.md +123 -0
  9. data/documentation/async_reactors.md +369 -0
  10. data/documentation/composition.md +199 -0
  11. data/documentation/core_concepts.md +662 -0
  12. data/documentation/data_pipelines.md +224 -0
  13. data/documentation/examples/inventory_management.md +749 -0
  14. data/documentation/examples/order_processing.md +365 -0
  15. data/documentation/examples/payment_processing.md +654 -0
  16. data/documentation/getting_started.md +224 -0
  17. data/documentation/retry_configuration.md +357 -0
  18. data/lib/ruby_reactor/async_router.rb +91 -0
  19. data/lib/ruby_reactor/configuration.rb +41 -0
  20. data/lib/ruby_reactor/context.rb +169 -0
  21. data/lib/ruby_reactor/context_serializer.rb +164 -0
  22. data/lib/ruby_reactor/dependency_graph.rb +126 -0
  23. data/lib/ruby_reactor/dsl/compose_builder.rb +86 -0
  24. data/lib/ruby_reactor/dsl/map_builder.rb +112 -0
  25. data/lib/ruby_reactor/dsl/reactor.rb +151 -0
  26. data/lib/ruby_reactor/dsl/step_builder.rb +177 -0
  27. data/lib/ruby_reactor/dsl/template_helpers.rb +36 -0
  28. data/lib/ruby_reactor/dsl/validation_helpers.rb +35 -0
  29. data/lib/ruby_reactor/error/base.rb +16 -0
  30. data/lib/ruby_reactor/error/compensation_error.rb +8 -0
  31. data/lib/ruby_reactor/error/context_too_large_error.rb +11 -0
  32. data/lib/ruby_reactor/error/dependency_error.rb +8 -0
  33. data/lib/ruby_reactor/error/deserialization_error.rb +11 -0
  34. data/lib/ruby_reactor/error/input_validation_error.rb +29 -0
  35. data/lib/ruby_reactor/error/schema_version_error.rb +11 -0
  36. data/lib/ruby_reactor/error/step_failure_error.rb +18 -0
  37. data/lib/ruby_reactor/error/undo_error.rb +8 -0
  38. data/lib/ruby_reactor/error/validation_error.rb +8 -0
  39. data/lib/ruby_reactor/executor/compensation_manager.rb +79 -0
  40. data/lib/ruby_reactor/executor/graph_manager.rb +41 -0
  41. data/lib/ruby_reactor/executor/input_validator.rb +39 -0
  42. data/lib/ruby_reactor/executor/result_handler.rb +103 -0
  43. data/lib/ruby_reactor/executor/retry_manager.rb +156 -0
  44. data/lib/ruby_reactor/executor/step_executor.rb +319 -0
  45. data/lib/ruby_reactor/executor.rb +123 -0
  46. data/lib/ruby_reactor/map/collector.rb +65 -0
  47. data/lib/ruby_reactor/map/element_executor.rb +154 -0
  48. data/lib/ruby_reactor/map/execution.rb +60 -0
  49. data/lib/ruby_reactor/map/helpers.rb +67 -0
  50. data/lib/ruby_reactor/max_retries_exhausted_failure.rb +19 -0
  51. data/lib/ruby_reactor/reactor.rb +75 -0
  52. data/lib/ruby_reactor/retry_context.rb +92 -0
  53. data/lib/ruby_reactor/retry_queued_result.rb +26 -0
  54. data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +13 -0
  55. data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +13 -0
  56. data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +15 -0
  57. data/lib/ruby_reactor/sidekiq_workers/worker.rb +55 -0
  58. data/lib/ruby_reactor/step/compose_step.rb +107 -0
  59. data/lib/ruby_reactor/step/map_step.rb +234 -0
  60. data/lib/ruby_reactor/step.rb +33 -0
  61. data/lib/ruby_reactor/storage/adapter.rb +51 -0
  62. data/lib/ruby_reactor/storage/configuration.rb +15 -0
  63. data/lib/ruby_reactor/storage/redis_adapter.rb +140 -0
  64. data/lib/ruby_reactor/template/base.rb +15 -0
  65. data/lib/ruby_reactor/template/element.rb +25 -0
  66. data/lib/ruby_reactor/template/input.rb +48 -0
  67. data/lib/ruby_reactor/template/result.rb +48 -0
  68. data/lib/ruby_reactor/template/value.rb +22 -0
  69. data/lib/ruby_reactor/validation/base.rb +26 -0
  70. data/lib/ruby_reactor/validation/input_validator.rb +62 -0
  71. data/lib/ruby_reactor/validation/schema_builder.rb +17 -0
  72. data/lib/ruby_reactor/version.rb +5 -0
  73. data/lib/ruby_reactor.rb +159 -0
  74. data/sig/ruby_reactor.rbs +4 -0
  75. metadata +178 -0
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class Context
5
+ attr_accessor :inputs, :intermediate_results, :private_data, :current_step, :retry_count, :concurrency_key,
6
+ :retry_context, :reactor_class, :execution_trace, :inline_async_execution, :undo_stack, :test_mode,
7
+ :parent_context, :root_context, :composed_contexts, :context_id, :map_operations, :map_metadata
8
+
9
+ def initialize(inputs = {}, reactor_class = nil)
10
+ @context_id = SecureRandom.uuid
11
+ @inputs = inputs
12
+ @intermediate_results = {}
13
+ @private_data = {}
14
+ @composed_contexts = {}
15
+ @map_operations = {}
16
+ @map_metadata = nil
17
+ @current_step = nil
18
+ @retry_count = 0
19
+ @concurrency_key = nil
20
+ @retry_context = RetryContext.new
21
+ @reactor_class = reactor_class
22
+ @execution_trace = []
23
+ @inline_async_execution = false # Flag to prevent nested async calls
24
+ @undo_stack = [] # Initialize the undo stack
25
+ @test_mode = false
26
+ @parent_context = nil
27
+ @root_context = nil
28
+ end
29
+
30
+ def get_input(name, path = nil)
31
+ value = @inputs[name.to_sym] || @inputs[name.to_s]
32
+ return nil if value.nil?
33
+
34
+ if path
35
+ extract_path(value, path)
36
+ else
37
+ value
38
+ end
39
+ end
40
+
41
+ def get_result(step_name, path = nil)
42
+ value = @intermediate_results[step_name.to_sym] || @intermediate_results[step_name.to_s]
43
+ return nil if value.nil?
44
+
45
+ if path
46
+ extract_path(value, path)
47
+ else
48
+ value
49
+ end
50
+ end
51
+
52
+ def set_result(step_name, value)
53
+ @intermediate_results[step_name.to_sym] = value
54
+ end
55
+
56
+ def with_step(step_name)
57
+ old_step = @current_step
58
+ @current_step = step_name
59
+ yield
60
+ ensure
61
+ @current_step = old_step
62
+ end
63
+
64
+ def to_h
65
+ {
66
+ inputs: @inputs,
67
+ intermediate_results: @intermediate_results,
68
+ composed_contexts: @composed_contexts,
69
+ map_operations: @map_operations,
70
+ map_metadata: @map_metadata,
71
+ current_step: @current_step,
72
+ retry_count: @retry_count,
73
+ retry_context: @retry_context,
74
+ reactor_class: @reactor_class,
75
+ execution_trace: @execution_trace,
76
+ test_mode: @test_mode
77
+ }
78
+ end
79
+
80
+ def serialize_for_retry(job_id: nil, started_at: nil)
81
+ {
82
+ job_id: job_id,
83
+ context_id: @context_id,
84
+ started_at: (started_at || Time.now).iso8601,
85
+ reactor_class: @reactor_class&.name,
86
+ inputs: ContextSerializer.serialize_value(@inputs),
87
+ intermediate_results: ContextSerializer.serialize_value(@intermediate_results),
88
+ private_data: ContextSerializer.serialize_value(@private_data),
89
+ composed_contexts: ContextSerializer.serialize_value(@composed_contexts),
90
+ map_operations: ContextSerializer.serialize_value(@map_operations),
91
+ map_metadata: ContextSerializer.serialize_value(@map_metadata),
92
+ current_step: @current_step,
93
+ retry_count: @retry_count,
94
+ concurrency_key: @concurrency_key,
95
+ retry_context: @retry_context.serialize_for_retry,
96
+ execution_trace: ContextSerializer.serialize_value(@execution_trace),
97
+ undo_stack: serialize_undo_stack,
98
+ test_mode: @test_mode
99
+ }
100
+ end
101
+
102
+ def self.deserialize_from_retry(data)
103
+ context = new
104
+ context.context_id = data["context_id"] if data["context_id"]
105
+ context.reactor_class = data["reactor_class"] ? Object.const_get(data["reactor_class"]) : nil
106
+ context.inputs = ContextSerializer.deserialize_value(data["inputs"]) || {}
107
+ context.intermediate_results = ContextSerializer.deserialize_value(data["intermediate_results"]) || {}
108
+ context.private_data = ContextSerializer.deserialize_value(data["private_data"]) || {}
109
+ context.composed_contexts = ContextSerializer.deserialize_value(data["composed_contexts"]) || {}
110
+ context.map_operations = ContextSerializer.deserialize_value(data["map_operations"]) || {}
111
+ context.map_metadata = ContextSerializer.deserialize_value(data["map_metadata"])
112
+ context.current_step = data["current_step"]&.to_sym
113
+ context.retry_count = data["retry_count"] || 0
114
+ context.concurrency_key = data["concurrency_key"]
115
+ context.retry_context = RetryContext.deserialize_from_retry(data["retry_context"] || {})
116
+ context.execution_trace = ContextSerializer.deserialize_value(data["execution_trace"]) || []
117
+ context.undo_stack = deserialize_undo_stack(data["undo_stack"] || [], context.reactor_class)
118
+ context.test_mode = data["test_mode"] || false
119
+
120
+ # Reconstruct parent/root relationships if nested contexts exist in private_data
121
+ # This is tricky because private_data is just a hash.
122
+ # We rely on the fact that nested contexts are stored in private_data by ComposeStep
123
+ # But here we just deserialize the values.
124
+
125
+ context
126
+ end
127
+
128
+ private
129
+
130
+ def extract_path(value, path)
131
+ if path.is_a?(Symbol) && value.respond_to?(:[])
132
+ value[path]
133
+ elsif path.is_a?(String)
134
+ path.split(".").reduce(value) { |v, key| v&.send(:[], key) }
135
+ elsif path.is_a?(Array)
136
+ path.reduce(value) { |v, key| v&.send(:[], key) }
137
+ elsif value.respond_to?(path)
138
+ value.send(path)
139
+ end
140
+ end
141
+
142
+ def serialize_undo_stack
143
+ @undo_stack.map do |item|
144
+ {
145
+ step_name: item[:step].name,
146
+ arguments: ContextSerializer.serialize_value(item[:arguments]),
147
+ result: ContextSerializer.serialize_value(item[:result])
148
+ }
149
+ end
150
+ end
151
+
152
+ def self.deserialize_undo_stack(data, reactor_class)
153
+ return [] unless reactor_class
154
+
155
+ data.map do |item|
156
+ step_config = reactor_class.steps[item["step_name"].to_sym]
157
+ next nil unless step_config
158
+
159
+ {
160
+ step: step_config,
161
+ arguments: ContextSerializer.deserialize_value(item["arguments"]),
162
+ result: ContextSerializer.deserialize_value(item["result"])
163
+ }
164
+ end.compact
165
+ end
166
+
167
+ private_class_method :deserialize_undo_stack
168
+ end
169
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ # Utility class for handling context serialization and deserialization
5
+ class ContextSerializer
6
+ MAX_CONTEXT_SIZE = 512 * 1024 * 1024 # 512MB Redis limit
7
+ SCHEMA_VERSION = "1.0"
8
+
9
+ class << self
10
+ def serialize(context, job_id: nil, started_at: nil)
11
+ data = context.serialize_for_retry(job_id: job_id, started_at: started_at)
12
+ data[:schema_version] = SCHEMA_VERSION
13
+
14
+ serialized = JSON.generate(data)
15
+ validate_size(serialized)
16
+
17
+ compress_if_needed(serialized)
18
+ end
19
+
20
+ def deserialize(serialized_data)
21
+ decompressed = decompress_if_needed(serialized_data)
22
+ data = JSON.parse(decompressed, symbolize_names: false)
23
+
24
+ validate_schema_version(data)
25
+
26
+ Context.deserialize_from_retry(data)
27
+ rescue JSON::ParserError => e
28
+ raise RubyReactor::Error::DeserializationError, "Failed to parse serialized context: #{e.message}"
29
+ end
30
+
31
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
32
+ def serialize_value(value)
33
+ case value
34
+ when RubyReactor::Success
35
+ { "_type" => "Success", "value" => serialize_value(value.value) }
36
+ when RubyReactor::Failure
37
+ { "_type" => "Failure", "error" => serialize_value(value.error), "retryable" => value.retryable }
38
+ when RubyReactor::Context
39
+ { "_type" => "Context", "value" => value.serialize_for_retry }
40
+ when Time
41
+ { "_type" => "Time", "value" => value.iso8601 }
42
+ when BigDecimal
43
+ { "_type" => "BigDecimal", "value" => value.to_s("F") }
44
+ when Rational
45
+ { "_type" => "Rational", "numerator" => value.numerator, "denominator" => value.denominator }
46
+ when Date
47
+ { "_type" => "Date", "value" => value.iso8601 }
48
+ when DateTime
49
+ { "_type" => "DateTime", "value" => value.iso8601 }
50
+ when Complex
51
+ { "_type" => "Complex", "real" => value.real, "imag" => value.imag }
52
+ when Range
53
+ { "_type" => "Range", "begin" => serialize_value(value.begin), "end" => serialize_value(value.end),
54
+ "exclude_end" => value.exclude_end? }
55
+ when Regexp
56
+ { "_type" => "Regexp", "source" => value.source, "options" => value.options }
57
+ when ->(v) { v.respond_to?(:to_global_id) }
58
+ { "_type" => "GlobalID", "gid" => value.to_global_id.to_s }
59
+ when RubyReactor::Template::Element
60
+ { "_type" => "Template::Element", "map_name" => value.map_name.to_s, "path" => value.path }
61
+ when RubyReactor::Template::Input
62
+ { "_type" => "Template::Input", "name" => value.name.to_s, "path" => value.path }
63
+ when RubyReactor::Template::Value
64
+ # Actually Template::Value holds a raw value. We should probably just serialize the raw value if possible,
65
+ # or keep it as a template if we need to distinguish.
66
+ # But wait, Template::Value is used to wrap raw values in arguments.
67
+ # If we serialize it, we should probably deserialize it back to Template::Value.
68
+ { "_type" => "Template::Value", "value" => serialize_value(value.instance_variable_get(:@value)) }
69
+ when RubyReactor::Template::Result
70
+ { "_type" => "Template::Result", "step_name" => value.step_name.to_s, "path" => value.path }
71
+ when Hash
72
+ value.transform_keys(&:to_s).transform_values { |v| serialize_value(v) }
73
+ when Array
74
+ value.map { |v| serialize_value(v) }
75
+ else
76
+ value
77
+ end
78
+ end
79
+
80
+ def deserialize_value(value)
81
+ case value
82
+ when Hash
83
+ if value.key?("_type")
84
+ # Special serialized types (Time, BigDecimal, etc.)
85
+ case value["_type"]
86
+ when "Success"
87
+ RubyReactor::Success(deserialize_value(value["value"]))
88
+ when "Failure"
89
+ RubyReactor::Failure(deserialize_value(value["error"]), retryable: value["retryable"])
90
+ when "Context"
91
+ Context.deserialize_from_retry(value["value"])
92
+ when "Time"
93
+ Time.iso8601(value["value"])
94
+ when "BigDecimal"
95
+ BigDecimal(value["value"])
96
+ when "Rational"
97
+ Rational(value["numerator"], value["denominator"])
98
+ when "Date"
99
+ Date.iso8601(value["value"])
100
+ when "DateTime"
101
+ DateTime.iso8601(value["value"])
102
+ when "Complex"
103
+ Complex(value["real"], value["imag"])
104
+ when "Range"
105
+ Range.new(deserialize_value(value["begin"]), deserialize_value(value["end"]), value["exclude_end"])
106
+ when "Regexp"
107
+ Regexp.new(value["source"], value["options"])
108
+ when "GlobalID"
109
+ GlobalID::Locator.locate(value["gid"])
110
+ when "Template::Element"
111
+ RubyReactor::Template::Element.new(value["map_name"], value["path"])
112
+ when "Template::Input"
113
+ RubyReactor::Template::Input.new(value["name"], value["path"])
114
+ when "Template::Value"
115
+ RubyReactor::Template::Value.new(deserialize_value(value["value"]))
116
+ when "Template::Result"
117
+ RubyReactor::Template::Result.new(value["step_name"], value["path"])
118
+ else
119
+ value
120
+ end
121
+ else
122
+ # Regular hash - symbolize all keys recursively
123
+ value.transform_keys(&:to_sym).transform_values { |v| deserialize_value(v) }
124
+ end
125
+ when Array
126
+ value.map { |v| deserialize_value(v) }
127
+ else
128
+ value
129
+ end
130
+ end
131
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
132
+
133
+ private
134
+
135
+ def validate_size(data)
136
+ size = data.bytesize
137
+ return if size <= MAX_CONTEXT_SIZE
138
+
139
+ raise RubyReactor::Error::ContextTooLargeError,
140
+ "Context size #{size} bytes exceeds maximum allowed size of #{MAX_CONTEXT_SIZE} bytes"
141
+ end
142
+
143
+ def compress_if_needed(data)
144
+ # For now, return uncompressed. Compression can be added later if needed
145
+ data
146
+ end
147
+
148
+ def decompress_if_needed(data)
149
+ # For now, assume uncompressed. Decompression logic can be added later
150
+ data
151
+ end
152
+
153
+ def validate_schema_version(data)
154
+ version = data["schema_version"]
155
+ return if version == SCHEMA_VERSION
156
+
157
+ # For now, only support exact version match
158
+ # Future versions could handle migration
159
+ raise RubyReactor::Error::SchemaVersionError,
160
+ "Unsupported schema version: #{version}. Expected: #{SCHEMA_VERSION}"
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ class DependencyGraph
5
+ def initialize
6
+ @nodes = {}
7
+ @edges = {}
8
+ @dependencies = {} # Store dependencies for each step
9
+ @completed = Set.new
10
+ end
11
+
12
+ def add_step(step_config)
13
+ step_name = step_config.name
14
+ @nodes[step_name] = step_config
15
+ @edges[step_name] = Set.new
16
+
17
+ # Calculate and store dependencies
18
+ dependencies = []
19
+
20
+ # Add dependencies from argument sources
21
+ step_config.arguments.each_value do |arg_config|
22
+ source = arg_config[:source]
23
+ if source.is_a?(RubyReactor::Template::Result)
24
+ dependencies << source.step_name
25
+ add_dependency(step_name, source.step_name)
26
+ end
27
+ end
28
+
29
+ # Add explicit dependencies
30
+ step_config.dependencies.each do |dep_step|
31
+ dependencies << dep_step
32
+ add_dependency(step_name, dep_step)
33
+ end
34
+
35
+ @dependencies[step_name] = dependencies.uniq
36
+ end
37
+
38
+ def add_dependency(step_name, dependency_name)
39
+ @edges[dependency_name] ||= Set.new
40
+ @edges[dependency_name] << step_name
41
+ end
42
+
43
+ def ready_steps
44
+ ready = []
45
+ @nodes.each do |step_name, step_config|
46
+ next if @completed.include?(step_name)
47
+
48
+ # Check if all dependencies are completed
49
+ dependencies = @dependencies[step_name] || []
50
+ ready << step_config if dependencies.all? { |dep| @completed.include?(dep) }
51
+ end
52
+ ready
53
+ end
54
+
55
+ def complete_step(step_name)
56
+ @completed << step_name
57
+ end
58
+
59
+ def has_cycles?
60
+ visited = Set.new
61
+ rec_stack = Set.new
62
+
63
+ @nodes.each_key do |node|
64
+ next if visited.include?(node)
65
+ return true if cycle_detected?(node, visited, rec_stack)
66
+ end
67
+
68
+ false
69
+ end
70
+
71
+ def topological_sort
72
+ return [] if has_cycles?
73
+
74
+ visited = Set.new
75
+ stack = []
76
+
77
+ @nodes.each_key do |node|
78
+ next if visited.include?(node)
79
+
80
+ topological_sort_util(node, visited, stack)
81
+ end
82
+
83
+ stack.reverse.map { |name| @nodes[name] }
84
+ end
85
+
86
+ def all_completed?
87
+ @completed.size == @nodes.size
88
+ end
89
+
90
+ def pending_steps
91
+ @nodes.keys - @completed.to_a
92
+ end
93
+
94
+ def topological_sort_util(node, visited, stack)
95
+ visited << node
96
+
97
+ dependents = @edges[node] || Set.new
98
+ dependents.each do |dependent|
99
+ next if visited.include?(dependent)
100
+
101
+ topological_sort_util(dependent, visited, stack)
102
+ end
103
+
104
+ stack << node
105
+ end
106
+
107
+ private
108
+
109
+ def cycle_detected?(node, visited, rec_stack)
110
+ visited << node
111
+ rec_stack << node
112
+
113
+ dependents = @edges[node] || Set.new
114
+ dependents.each do |dependent|
115
+ if !visited.include?(dependent)
116
+ return true if cycle_detected?(dependent, visited, rec_stack)
117
+ elsif rec_stack.include?(dependent)
118
+ return true
119
+ end
120
+ end
121
+
122
+ rec_stack.delete(node)
123
+ false
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Dsl
5
+ class ComposeBuilder
6
+ include RubyReactor::Dsl::TemplateHelpers
7
+
8
+ attr_accessor :name, :composed_reactor_class, :argument_mappings
9
+
10
+ def initialize(name, composed_reactor_class = nil, reactor = nil, &block)
11
+ @name = name
12
+ @composed_reactor_class = composed_reactor_class || (block ? Class.new(RubyReactor::Reactor) : nil)
13
+ @reactor = reactor
14
+ @argument_mappings = {}
15
+ @async = false
16
+ @retry_config = {}
17
+ end
18
+
19
+ def argument(composed_input_name, source)
20
+ @argument_mappings[composed_input_name] = source
21
+ end
22
+
23
+ def async(async = true)
24
+ @async = async
25
+ end
26
+
27
+ def retries(max_attempts: 3, backoff: :exponential, base_delay: 1)
28
+ @retry_config = {
29
+ max_attempts: max_attempts,
30
+ backoff: backoff,
31
+ base_delay: base_delay
32
+ }
33
+ end
34
+
35
+ def build
36
+ dependencies = extract_dependencies_from_mappings
37
+
38
+ step_config = {
39
+ name: @name,
40
+ impl: RubyReactor::Step::ComposeStep,
41
+ arguments: {
42
+ composed_reactor_class: { source: RubyReactor::Template::Value.new(@composed_reactor_class) },
43
+ argument_mappings: { source: RubyReactor::Template::Value.new(@argument_mappings) }
44
+ },
45
+ run_block: nil,
46
+ compensate_block: nil,
47
+ undo_block: nil,
48
+ conditions: [],
49
+ guards: [],
50
+ dependencies: dependencies,
51
+ args_validator: nil,
52
+ output_validator: nil,
53
+ async: @async,
54
+ retry_config: @retry_config.empty? ? (@reactor&.retry_defaults || {}) : @retry_config
55
+ }
56
+
57
+ RubyReactor::Dsl::StepConfig.new(step_config)
58
+ end
59
+
60
+ # Delegate step definition methods to the composed reactor class
61
+ def step(name, &block)
62
+ ensure_composed_reactor_class!
63
+ @composed_reactor_class.step(name, &block)
64
+ end
65
+
66
+ def compose(name, reactor_class = nil, &block)
67
+ ensure_composed_reactor_class!
68
+ @composed_reactor_class.compose(name, reactor_class, &block)
69
+ end
70
+
71
+ private
72
+
73
+ def ensure_composed_reactor_class!
74
+ raise ArgumentError, "No block provided for inline compose" unless @composed_reactor_class
75
+ end
76
+
77
+ def extract_dependencies_from_mappings
78
+ dependencies = []
79
+ @argument_mappings.each_value do |source|
80
+ dependencies << source.step_name if source.is_a?(RubyReactor::Template::Result)
81
+ end
82
+ dependencies.uniq
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyReactor
4
+ module Dsl
5
+ class MapBuilder
6
+ include RubyReactor::Dsl::TemplateHelpers
7
+
8
+ attr_accessor :name, :mapped_reactor_class, :argument_mappings, :source_enumerable
9
+
10
+ def initialize(name, mapped_reactor_class = nil, reactor = nil, &block)
11
+ @name = name
12
+ @mapped_reactor_class = mapped_reactor_class || (block ? Class.new(RubyReactor::Reactor) : nil)
13
+ @reactor = reactor
14
+ @argument_mappings = {}
15
+ @async = false
16
+ @strict_ordering = true
17
+ @batch_size = nil
18
+ @source_enumerable = nil
19
+ @collect_block = nil
20
+ @fail_fast = true # Default: stop on first error
21
+ end
22
+
23
+ def argument(mapped_input_name, source)
24
+ @argument_mappings[mapped_input_name] = source
25
+ end
26
+
27
+ def source(enumerable)
28
+ @source_enumerable = enumerable
29
+ end
30
+
31
+ def async(async = true, batch_size: nil)
32
+ @async = async
33
+ @batch_size = batch_size if batch_size
34
+ end
35
+
36
+ def strict_ordering(enabled = true)
37
+ @strict_ordering = enabled
38
+ end
39
+
40
+ def batch_size(size)
41
+ @batch_size = size
42
+ end
43
+
44
+ def collect(&block)
45
+ @collect_block = block
46
+ end
47
+
48
+ def fail_fast(enabled = true)
49
+ @fail_fast = enabled
50
+ end
51
+
52
+ def build
53
+ dependencies = extract_dependencies_from_mappings
54
+ dependencies << @source_enumerable.step_name if @source_enumerable.is_a?(RubyReactor::Template::Result)
55
+
56
+ RubyReactor::Dsl::StepConfig.new(build_step_config(dependencies))
57
+ end
58
+
59
+ # Delegate step definition methods to the mapped reactor class
60
+ def step(name, &block)
61
+ ensure_mapped_reactor_class!
62
+ @mapped_reactor_class.step(name, &block)
63
+ end
64
+
65
+ def returns(step_name)
66
+ ensure_mapped_reactor_class!
67
+ @mapped_reactor_class.returns(step_name)
68
+ end
69
+ alias return returns
70
+
71
+ private
72
+
73
+ def ensure_mapped_reactor_class!
74
+ raise ArgumentError, "No block provided for inline map" unless @mapped_reactor_class
75
+ end
76
+
77
+ def build_step_config(dependencies)
78
+ {
79
+ name: @name,
80
+ impl: RubyReactor::Step::MapStep,
81
+ arguments: {
82
+ mapped_reactor_class: { source: RubyReactor::Template::Value.new(@mapped_reactor_class) },
83
+ argument_mappings: { source: RubyReactor::Template::Value.new(@argument_mappings) },
84
+ source: { source: @source_enumerable },
85
+ strict_ordering: { source: RubyReactor::Template::Value.new(@strict_ordering) },
86
+ batch_size: { source: RubyReactor::Template::Value.new(@batch_size) },
87
+ collect_block: { source: RubyReactor::Template::Value.new(@collect_block) },
88
+ fail_fast: { source: RubyReactor::Template::Value.new(@fail_fast) },
89
+ async: { source: RubyReactor::Template::Value.new(@async) }
90
+ },
91
+ run_block: nil,
92
+ compensate_block: nil,
93
+ undo_block: nil,
94
+ conditions: [],
95
+ guards: [],
96
+ dependencies: dependencies.uniq,
97
+ args_validator: nil,
98
+ output_validator: nil,
99
+ async: false # MapStep handles async internally via run_async
100
+ }
101
+ end
102
+
103
+ def extract_dependencies_from_mappings
104
+ dependencies = []
105
+ @argument_mappings.each_value do |source|
106
+ dependencies << source.step_name if source.is_a?(RubyReactor::Template::Result)
107
+ end
108
+ dependencies
109
+ end
110
+ end
111
+ end
112
+ end