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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +98 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/README.md +570 -0
- data/Rakefile +12 -0
- data/documentation/DAG.md +457 -0
- data/documentation/README.md +123 -0
- data/documentation/async_reactors.md +369 -0
- data/documentation/composition.md +199 -0
- data/documentation/core_concepts.md +662 -0
- data/documentation/data_pipelines.md +224 -0
- data/documentation/examples/inventory_management.md +749 -0
- data/documentation/examples/order_processing.md +365 -0
- data/documentation/examples/payment_processing.md +654 -0
- data/documentation/getting_started.md +224 -0
- data/documentation/retry_configuration.md +357 -0
- data/lib/ruby_reactor/async_router.rb +91 -0
- data/lib/ruby_reactor/configuration.rb +41 -0
- data/lib/ruby_reactor/context.rb +169 -0
- data/lib/ruby_reactor/context_serializer.rb +164 -0
- data/lib/ruby_reactor/dependency_graph.rb +126 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +86 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +112 -0
- data/lib/ruby_reactor/dsl/reactor.rb +151 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +177 -0
- data/lib/ruby_reactor/dsl/template_helpers.rb +36 -0
- data/lib/ruby_reactor/dsl/validation_helpers.rb +35 -0
- data/lib/ruby_reactor/error/base.rb +16 -0
- data/lib/ruby_reactor/error/compensation_error.rb +8 -0
- data/lib/ruby_reactor/error/context_too_large_error.rb +11 -0
- data/lib/ruby_reactor/error/dependency_error.rb +8 -0
- data/lib/ruby_reactor/error/deserialization_error.rb +11 -0
- data/lib/ruby_reactor/error/input_validation_error.rb +29 -0
- data/lib/ruby_reactor/error/schema_version_error.rb +11 -0
- data/lib/ruby_reactor/error/step_failure_error.rb +18 -0
- data/lib/ruby_reactor/error/undo_error.rb +8 -0
- data/lib/ruby_reactor/error/validation_error.rb +8 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +79 -0
- data/lib/ruby_reactor/executor/graph_manager.rb +41 -0
- data/lib/ruby_reactor/executor/input_validator.rb +39 -0
- data/lib/ruby_reactor/executor/result_handler.rb +103 -0
- data/lib/ruby_reactor/executor/retry_manager.rb +156 -0
- data/lib/ruby_reactor/executor/step_executor.rb +319 -0
- data/lib/ruby_reactor/executor.rb +123 -0
- data/lib/ruby_reactor/map/collector.rb +65 -0
- data/lib/ruby_reactor/map/element_executor.rb +154 -0
- data/lib/ruby_reactor/map/execution.rb +60 -0
- data/lib/ruby_reactor/map/helpers.rb +67 -0
- data/lib/ruby_reactor/max_retries_exhausted_failure.rb +19 -0
- data/lib/ruby_reactor/reactor.rb +75 -0
- data/lib/ruby_reactor/retry_context.rb +92 -0
- data/lib/ruby_reactor/retry_queued_result.rb +26 -0
- data/lib/ruby_reactor/sidekiq_workers/map_collector_worker.rb +13 -0
- data/lib/ruby_reactor/sidekiq_workers/map_element_worker.rb +13 -0
- data/lib/ruby_reactor/sidekiq_workers/map_execution_worker.rb +15 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +55 -0
- data/lib/ruby_reactor/step/compose_step.rb +107 -0
- data/lib/ruby_reactor/step/map_step.rb +234 -0
- data/lib/ruby_reactor/step.rb +33 -0
- data/lib/ruby_reactor/storage/adapter.rb +51 -0
- data/lib/ruby_reactor/storage/configuration.rb +15 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +140 -0
- data/lib/ruby_reactor/template/base.rb +15 -0
- data/lib/ruby_reactor/template/element.rb +25 -0
- data/lib/ruby_reactor/template/input.rb +48 -0
- data/lib/ruby_reactor/template/result.rb +48 -0
- data/lib/ruby_reactor/template/value.rb +22 -0
- data/lib/ruby_reactor/validation/base.rb +26 -0
- data/lib/ruby_reactor/validation/input_validator.rb +62 -0
- data/lib/ruby_reactor/validation/schema_builder.rb +17 -0
- data/lib/ruby_reactor/version.rb +5 -0
- data/lib/ruby_reactor.rb +159 -0
- data/sig/ruby_reactor.rbs +4 -0
- 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
|