fractor 0.1.6 → 0.1.8
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 +4 -4
- data/.rubocop_todo.yml +227 -102
- data/README.adoc +113 -1940
- data/docs/.lycheeignore +16 -0
- data/docs/Gemfile +24 -0
- data/docs/README.md +157 -0
- data/docs/_config.yml +151 -0
- data/docs/_features/error-handling.adoc +1192 -0
- data/docs/_features/index.adoc +80 -0
- data/docs/_features/monitoring.adoc +589 -0
- data/docs/_features/signal-handling.adoc +202 -0
- data/docs/_features/workflows.adoc +1235 -0
- data/docs/_guides/continuous-mode.adoc +736 -0
- data/docs/_guides/cookbook.adoc +1133 -0
- data/docs/_guides/index.adoc +55 -0
- data/docs/_guides/pipeline-mode.adoc +730 -0
- data/docs/_guides/troubleshooting.adoc +358 -0
- data/docs/_pages/architecture.adoc +1390 -0
- data/docs/_pages/core-concepts.adoc +1392 -0
- data/docs/_pages/design-principles.adoc +862 -0
- data/docs/_pages/getting-started.adoc +290 -0
- data/docs/_pages/installation.adoc +143 -0
- data/docs/_reference/api.adoc +1080 -0
- data/docs/_reference/error-reporting.adoc +670 -0
- data/docs/_reference/examples.adoc +181 -0
- data/docs/_reference/index.adoc +96 -0
- data/docs/_reference/troubleshooting.adoc +862 -0
- data/docs/_tutorials/complex-workflows.adoc +1022 -0
- data/docs/_tutorials/data-processing-pipeline.adoc +740 -0
- data/docs/_tutorials/first-application.adoc +384 -0
- data/docs/_tutorials/index.adoc +48 -0
- data/docs/_tutorials/long-running-services.adoc +931 -0
- data/docs/assets/images/favicon-16.png +0 -0
- data/docs/assets/images/favicon-32.png +0 -0
- data/docs/assets/images/favicon-48.png +0 -0
- data/docs/assets/images/favicon.ico +0 -0
- data/docs/assets/images/favicon.png +0 -0
- data/docs/assets/images/favicon.svg +45 -0
- data/docs/assets/images/fractor-icon.svg +49 -0
- data/docs/assets/images/fractor-logo.svg +61 -0
- data/docs/index.adoc +131 -0
- data/docs/lychee.toml +39 -0
- data/examples/api_aggregator/README.adoc +627 -0
- data/examples/api_aggregator/api_aggregator.rb +376 -0
- data/examples/auto_detection/README.adoc +407 -29
- data/examples/continuous_chat_common/message_protocol.rb +1 -1
- data/examples/error_reporting.rb +207 -0
- data/examples/file_processor/README.adoc +170 -0
- data/examples/file_processor/file_processor.rb +615 -0
- data/examples/file_processor/sample_files/invalid.csv +1 -0
- data/examples/file_processor/sample_files/orders.xml +24 -0
- data/examples/file_processor/sample_files/products.json +23 -0
- data/examples/file_processor/sample_files/users.csv +6 -0
- data/examples/hierarchical_hasher/README.adoc +629 -41
- data/examples/image_processor/README.adoc +610 -0
- data/examples/image_processor/image_processor.rb +349 -0
- data/examples/image_processor/processed_images/sample_10_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_1_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_2_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_3_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_4_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_5_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_6_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_7_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_8_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_9_processed.jpg.json +12 -0
- data/examples/image_processor/test_images/sample_1.png +1 -0
- data/examples/image_processor/test_images/sample_10.png +1 -0
- data/examples/image_processor/test_images/sample_2.png +1 -0
- data/examples/image_processor/test_images/sample_3.png +1 -0
- data/examples/image_processor/test_images/sample_4.png +1 -0
- data/examples/image_processor/test_images/sample_5.png +1 -0
- data/examples/image_processor/test_images/sample_6.png +1 -0
- data/examples/image_processor/test_images/sample_7.png +1 -0
- data/examples/image_processor/test_images/sample_8.png +1 -0
- data/examples/image_processor/test_images/sample_9.png +1 -0
- data/examples/log_analyzer/README.adoc +662 -0
- data/examples/log_analyzer/log_analyzer.rb +579 -0
- data/examples/log_analyzer/sample_logs/apache.log +20 -0
- data/examples/log_analyzer/sample_logs/json.log +15 -0
- data/examples/log_analyzer/sample_logs/nginx.log +15 -0
- data/examples/log_analyzer/sample_logs/rails.log +29 -0
- data/examples/multi_work_type/README.adoc +576 -26
- data/examples/performance_monitoring.rb +120 -0
- data/examples/pipeline_processing/README.adoc +740 -26
- data/examples/pipeline_processing/pipeline_processing.rb +2 -2
- data/examples/priority_work_example.rb +155 -0
- data/examples/producer_subscriber/README.adoc +889 -46
- data/examples/scatter_gather/README.adoc +829 -27
- data/examples/simple/README.adoc +347 -0
- data/examples/specialized_workers/README.adoc +622 -26
- data/examples/specialized_workers/specialized_workers.rb +44 -8
- data/examples/stream_processor/README.adoc +206 -0
- data/examples/stream_processor/stream_processor.rb +284 -0
- data/examples/web_scraper/README.adoc +625 -0
- data/examples/web_scraper/web_scraper.rb +285 -0
- data/examples/workflow/README.adoc +406 -0
- data/examples/workflow/circuit_breaker/README.adoc +360 -0
- data/examples/workflow/circuit_breaker/circuit_breaker_workflow.rb +225 -0
- data/examples/workflow/conditional/README.adoc +483 -0
- data/examples/workflow/conditional/conditional_workflow.rb +215 -0
- data/examples/workflow/dead_letter_queue/README.adoc +374 -0
- data/examples/workflow/dead_letter_queue/dead_letter_queue_workflow.rb +217 -0
- data/examples/workflow/fan_out/README.adoc +381 -0
- data/examples/workflow/fan_out/fan_out_workflow.rb +202 -0
- data/examples/workflow/retry/README.adoc +248 -0
- data/examples/workflow/retry/retry_workflow.rb +195 -0
- data/examples/workflow/simple_linear/README.adoc +267 -0
- data/examples/workflow/simple_linear/simple_linear_workflow.rb +175 -0
- data/examples/workflow/simplified/README.adoc +329 -0
- data/examples/workflow/simplified/simplified_workflow.rb +222 -0
- data/exe/fractor +10 -0
- data/lib/fractor/cli.rb +288 -0
- data/lib/fractor/configuration.rb +307 -0
- data/lib/fractor/continuous_server.rb +60 -65
- data/lib/fractor/error_formatter.rb +72 -0
- data/lib/fractor/error_report_generator.rb +152 -0
- data/lib/fractor/error_reporter.rb +244 -0
- data/lib/fractor/error_statistics.rb +147 -0
- data/lib/fractor/execution_tracer.rb +162 -0
- data/lib/fractor/logger.rb +230 -0
- data/lib/fractor/main_loop_handler.rb +406 -0
- data/lib/fractor/main_loop_handler3.rb +135 -0
- data/lib/fractor/main_loop_handler4.rb +299 -0
- data/lib/fractor/performance_metrics_collector.rb +181 -0
- data/lib/fractor/performance_monitor.rb +215 -0
- data/lib/fractor/performance_report_generator.rb +202 -0
- data/lib/fractor/priority_work.rb +93 -0
- data/lib/fractor/priority_work_queue.rb +189 -0
- data/lib/fractor/result_aggregator.rb +32 -0
- data/lib/fractor/shutdown_handler.rb +168 -0
- data/lib/fractor/signal_handler.rb +80 -0
- data/lib/fractor/supervisor.rb +382 -269
- data/lib/fractor/supervisor_logger.rb +88 -0
- data/lib/fractor/version.rb +1 -1
- data/lib/fractor/work.rb +12 -0
- data/lib/fractor/work_distribution_manager.rb +151 -0
- data/lib/fractor/work_queue.rb +20 -0
- data/lib/fractor/work_result.rb +181 -9
- data/lib/fractor/worker.rb +73 -0
- data/lib/fractor/workflow/builder.rb +210 -0
- data/lib/fractor/workflow/chain_builder.rb +169 -0
- data/lib/fractor/workflow/circuit_breaker.rb +183 -0
- data/lib/fractor/workflow/circuit_breaker_orchestrator.rb +208 -0
- data/lib/fractor/workflow/circuit_breaker_registry.rb +112 -0
- data/lib/fractor/workflow/dead_letter_queue.rb +334 -0
- data/lib/fractor/workflow/execution_hooks.rb +39 -0
- data/lib/fractor/workflow/execution_strategy.rb +225 -0
- data/lib/fractor/workflow/execution_trace.rb +134 -0
- data/lib/fractor/workflow/helpers.rb +191 -0
- data/lib/fractor/workflow/job.rb +290 -0
- data/lib/fractor/workflow/job_dependency_validator.rb +120 -0
- data/lib/fractor/workflow/logger.rb +110 -0
- data/lib/fractor/workflow/pre_execution_context.rb +193 -0
- data/lib/fractor/workflow/retry_config.rb +156 -0
- data/lib/fractor/workflow/retry_orchestrator.rb +184 -0
- data/lib/fractor/workflow/retry_strategy.rb +93 -0
- data/lib/fractor/workflow/structured_logger.rb +30 -0
- data/lib/fractor/workflow/type_compatibility_validator.rb +222 -0
- data/lib/fractor/workflow/visualizer.rb +211 -0
- data/lib/fractor/workflow/workflow_context.rb +132 -0
- data/lib/fractor/workflow/workflow_executor.rb +669 -0
- data/lib/fractor/workflow/workflow_result.rb +55 -0
- data/lib/fractor/workflow/workflow_validator.rb +295 -0
- data/lib/fractor/workflow.rb +333 -0
- data/lib/fractor/wrapped_ractor.rb +66 -101
- data/lib/fractor/wrapped_ractor3.rb +161 -0
- data/lib/fractor/wrapped_ractor4.rb +242 -0
- data/lib/fractor.rb +92 -4
- metadata +179 -6
- data/tests/sample.rb.bak +0 -309
- data/tests/sample_working.rb.bak +0 -209
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fractor
|
|
4
|
+
class Workflow
|
|
5
|
+
# Programmatic API for building workflows without DSL
|
|
6
|
+
# Useful for generating workflows dynamically
|
|
7
|
+
#
|
|
8
|
+
# Example:
|
|
9
|
+
# builder = Fractor::Workflow::Builder.new("my-workflow")
|
|
10
|
+
# builder.input_type(InputData)
|
|
11
|
+
# builder.output_type(OutputData)
|
|
12
|
+
# builder.add_job("process", ProcessWorker, inputs: :workflow)
|
|
13
|
+
# builder.add_job("finalize", FinalizeWorker, needs: "process", inputs: "process")
|
|
14
|
+
# workflow_class = builder.build
|
|
15
|
+
# workflow = workflow_class.new
|
|
16
|
+
# result = workflow.execute(input: data)
|
|
17
|
+
class Builder
|
|
18
|
+
attr_reader :name, :jobs, :input_type_class, :output_type_class
|
|
19
|
+
|
|
20
|
+
def initialize(name)
|
|
21
|
+
@name = name
|
|
22
|
+
@jobs = []
|
|
23
|
+
@input_type_class = nil
|
|
24
|
+
@output_type_class = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Set input type for the workflow
|
|
28
|
+
def input_type(klass)
|
|
29
|
+
@input_type_class = klass
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Set output type for the workflow
|
|
34
|
+
def output_type(klass)
|
|
35
|
+
@output_type_class = klass
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Add a job to the workflow
|
|
40
|
+
#
|
|
41
|
+
# @param id [String] Job identifier
|
|
42
|
+
# @param worker [Class] Worker class
|
|
43
|
+
# @param needs [String, Array<String>] Job dependencies
|
|
44
|
+
# @param inputs [Symbol, String, Hash] Input configuration
|
|
45
|
+
# @param condition [Proc] Conditional execution lambda
|
|
46
|
+
# @param outputs_to_workflow [Boolean] Whether job outputs to workflow
|
|
47
|
+
# @param terminates [Boolean] Whether job terminates workflow
|
|
48
|
+
def add_job(id, worker, needs: nil, inputs: nil, condition: nil,
|
|
49
|
+
outputs_to_workflow: false, terminates: false)
|
|
50
|
+
@jobs << {
|
|
51
|
+
id: id,
|
|
52
|
+
worker: worker,
|
|
53
|
+
needs: needs,
|
|
54
|
+
inputs: inputs,
|
|
55
|
+
condition: condition,
|
|
56
|
+
outputs_to_workflow: outputs_to_workflow,
|
|
57
|
+
terminates: terminates,
|
|
58
|
+
}
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Remove a job by id
|
|
63
|
+
def remove_job(id)
|
|
64
|
+
@jobs.reject! { |j| j[:id] == id }
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Update a job
|
|
69
|
+
def update_job(id, **options)
|
|
70
|
+
job = @jobs.find { |j| j[:id] == id }
|
|
71
|
+
return self unless job
|
|
72
|
+
|
|
73
|
+
job.merge!(options.compact)
|
|
74
|
+
self
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Build the workflow class
|
|
78
|
+
def build
|
|
79
|
+
builder_name = @name
|
|
80
|
+
builder_input_type = @input_type_class
|
|
81
|
+
builder_output_type = @output_type_class
|
|
82
|
+
builder_jobs = @jobs.dup
|
|
83
|
+
|
|
84
|
+
# Define helper methods that will be available
|
|
85
|
+
find_start_jobs_proc = lambda do |jobs|
|
|
86
|
+
jobs.select { |j| j[:needs].nil? || j[:needs].empty? }
|
|
87
|
+
.map { |j| j[:id] }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
find_end_jobs_proc = lambda do |jobs|
|
|
91
|
+
jobs.select { |j| j[:outputs_to_workflow] || j[:terminates] }
|
|
92
|
+
.map { |j| j[:id] }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
configure_inputs_proc = lambda do |job_dsl, inputs_config|
|
|
96
|
+
return unless inputs_config
|
|
97
|
+
|
|
98
|
+
case inputs_config
|
|
99
|
+
when :workflow, "workflow"
|
|
100
|
+
job_dsl.inputs_from_workflow
|
|
101
|
+
when String
|
|
102
|
+
job_dsl.inputs_from_job(inputs_config)
|
|
103
|
+
when Hash
|
|
104
|
+
if inputs_config[:from_job]
|
|
105
|
+
job_dsl.inputs_from_job(inputs_config[:from_job])
|
|
106
|
+
elsif inputs_config[:from_multiple]
|
|
107
|
+
job_dsl.inputs_from_multiple(inputs_config[:from_multiple])
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
Class.new(Fractor::Workflow) do
|
|
113
|
+
workflow builder_name do
|
|
114
|
+
input_type builder_input_type if builder_input_type
|
|
115
|
+
output_type builder_output_type if builder_output_type
|
|
116
|
+
|
|
117
|
+
# Determine start and end jobs
|
|
118
|
+
start_jobs = find_start_jobs_proc.call(builder_jobs)
|
|
119
|
+
end_jobs = find_end_jobs_proc.call(builder_jobs)
|
|
120
|
+
|
|
121
|
+
start_with(*start_jobs) if start_jobs.any?
|
|
122
|
+
|
|
123
|
+
end_jobs.each do |end_job|
|
|
124
|
+
end_with end_job, on: :success
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Define each job
|
|
128
|
+
builder_jobs.each do |job_config|
|
|
129
|
+
job_id = job_config[:id]
|
|
130
|
+
worker_class = job_config[:worker]
|
|
131
|
+
needs_list = job_config[:needs]
|
|
132
|
+
inputs_config = job_config[:inputs]
|
|
133
|
+
condition_proc = job_config[:condition]
|
|
134
|
+
outputs = job_config[:outputs_to_workflow]
|
|
135
|
+
terminates_flag = job_config[:terminates]
|
|
136
|
+
|
|
137
|
+
job job_id do
|
|
138
|
+
runs_with worker_class if worker_class
|
|
139
|
+
|
|
140
|
+
if needs_list
|
|
141
|
+
needs_array = needs_list.is_a?(Array) ? needs_list : [needs_list]
|
|
142
|
+
needs(*needs_array)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
configure_inputs_proc.call(self, inputs_config)
|
|
146
|
+
|
|
147
|
+
if_condition condition_proc if condition_proc
|
|
148
|
+
|
|
149
|
+
outputs_to_workflow if outputs || end_jobs.include?(job_id)
|
|
150
|
+
terminates_workflow if terminates_flag || end_jobs.include?(job_id)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Validate the workflow configuration
|
|
158
|
+
def validate!
|
|
159
|
+
if @name.nil? || @name.empty?
|
|
160
|
+
raise ArgumentError,
|
|
161
|
+
"Workflow must have a name"
|
|
162
|
+
end
|
|
163
|
+
if @jobs.empty?
|
|
164
|
+
raise ArgumentError,
|
|
165
|
+
"Workflow must have at least one job"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Check for duplicate job IDs
|
|
169
|
+
job_ids = @jobs.map { |j| j[:id] }
|
|
170
|
+
duplicates = job_ids.select { |id| job_ids.count(id) > 1 }.uniq
|
|
171
|
+
if duplicates.any?
|
|
172
|
+
raise ArgumentError,
|
|
173
|
+
"Duplicate job IDs: #{duplicates.join(', ')}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Check for missing dependencies
|
|
177
|
+
@jobs.each do |job|
|
|
178
|
+
needs = job[:needs]
|
|
179
|
+
next unless needs
|
|
180
|
+
|
|
181
|
+
needs_array = needs.is_a?(Array) ? needs : [needs]
|
|
182
|
+
needs_array.each do |dep|
|
|
183
|
+
unless job_ids.include?(dep)
|
|
184
|
+
raise ArgumentError,
|
|
185
|
+
"Job '#{job[:id]}' depends on non-existent job '#{dep}'"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
true
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Build and validate in one step
|
|
194
|
+
def build!
|
|
195
|
+
validate!
|
|
196
|
+
build
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Clone this builder
|
|
200
|
+
def clone
|
|
201
|
+
new_builder = self.class.new(@name)
|
|
202
|
+
new_builder.instance_variable_set(:@input_type_class, @input_type_class)
|
|
203
|
+
new_builder.instance_variable_set(:@output_type_class,
|
|
204
|
+
@output_type_class)
|
|
205
|
+
new_builder.instance_variable_set(:@jobs, @jobs.dup)
|
|
206
|
+
new_builder
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fractor
|
|
4
|
+
class Workflow
|
|
5
|
+
# Fluent API for building linear chain workflows.
|
|
6
|
+
# Simplifies creation of sequential processing pipelines.
|
|
7
|
+
#
|
|
8
|
+
# @example Using chain builder
|
|
9
|
+
# workflow = Fractor::Workflow.chain("text-pipeline")
|
|
10
|
+
# .step("uppercase", UppercaseWorker)
|
|
11
|
+
# .step("reverse", ReverseWorker)
|
|
12
|
+
# .step("finalize", FinalizeWorker)
|
|
13
|
+
# .build
|
|
14
|
+
#
|
|
15
|
+
# instance = workflow.new
|
|
16
|
+
# result = instance.execute(input: data)
|
|
17
|
+
#
|
|
18
|
+
# @example Using define class method
|
|
19
|
+
# workflow = Fractor::Workflow::ChainBuilder.define("text-pipeline") do |chain|
|
|
20
|
+
# chain.step("uppercase", UppercaseWorker)
|
|
21
|
+
# chain.step("reverse", ReverseWorker)
|
|
22
|
+
# chain.step("finalize", FinalizeWorker)
|
|
23
|
+
# end
|
|
24
|
+
class ChainBuilder
|
|
25
|
+
attr_reader :name, :steps
|
|
26
|
+
|
|
27
|
+
# Define a chain workflow using a block.
|
|
28
|
+
# This is a convenience method that creates and builds a ChainBuilder.
|
|
29
|
+
#
|
|
30
|
+
# @param name [String] The workflow name
|
|
31
|
+
# @yield [ChainBuilder] Block that receives the chain builder
|
|
32
|
+
# @return [Class] A new Workflow subclass
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# workflow = Fractor::Workflow::ChainBuilder.define("my-chain") do |chain|
|
|
36
|
+
# chain.step("process", MyWorker)
|
|
37
|
+
# chain.step("finalize", FinalizeWorker)
|
|
38
|
+
# end
|
|
39
|
+
def self.define(name, &block)
|
|
40
|
+
builder = new(name)
|
|
41
|
+
builder.instance_eval(&block) if block
|
|
42
|
+
builder.build
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(name)
|
|
46
|
+
@name = name
|
|
47
|
+
@steps = []
|
|
48
|
+
@input_type_class = nil
|
|
49
|
+
@output_type_class = nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Set the input type for the workflow
|
|
53
|
+
#
|
|
54
|
+
# @param klass [Class] The input type class
|
|
55
|
+
# @return [ChainBuilder] self for chaining
|
|
56
|
+
def input_type(klass)
|
|
57
|
+
@input_type_class = klass
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Set the output type for the workflow
|
|
62
|
+
#
|
|
63
|
+
# @param klass [Class] The output type class
|
|
64
|
+
# @return [ChainBuilder] self for chaining
|
|
65
|
+
def output_type(klass)
|
|
66
|
+
@output_type_class = klass
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Add a step to the chain
|
|
71
|
+
#
|
|
72
|
+
# @param name [String, Symbol] The step name
|
|
73
|
+
# @param worker [Class] The worker class for this step
|
|
74
|
+
# @param workers [Integer] Optional number of parallel workers
|
|
75
|
+
# @param condition [Proc] Optional conditional execution
|
|
76
|
+
# @return [ChainBuilder] self for chaining
|
|
77
|
+
def step(name, worker, workers: nil, condition: nil)
|
|
78
|
+
step_config = {
|
|
79
|
+
name: name.to_s,
|
|
80
|
+
worker: worker,
|
|
81
|
+
workers: workers,
|
|
82
|
+
condition: condition,
|
|
83
|
+
}
|
|
84
|
+
@steps << step_config
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Build the workflow class
|
|
89
|
+
#
|
|
90
|
+
# @return [Class] A new Workflow subclass
|
|
91
|
+
def build
|
|
92
|
+
chain_name = @name
|
|
93
|
+
chain_steps = @steps.dup
|
|
94
|
+
chain_input_type = @input_type_class
|
|
95
|
+
chain_output_type = @output_type_class
|
|
96
|
+
|
|
97
|
+
Class.new(Workflow) do
|
|
98
|
+
workflow chain_name do
|
|
99
|
+
input_type chain_input_type if chain_input_type
|
|
100
|
+
output_type chain_output_type if chain_output_type
|
|
101
|
+
|
|
102
|
+
# Build jobs sequentially
|
|
103
|
+
chain_steps.each_with_index do |step_config, index|
|
|
104
|
+
step_name = step_config[:name]
|
|
105
|
+
step_worker = step_config[:worker]
|
|
106
|
+
step_workers = step_config[:workers]
|
|
107
|
+
step_condition = step_config[:condition]
|
|
108
|
+
|
|
109
|
+
# Determine dependencies
|
|
110
|
+
needs_job = index.positive? ? chain_steps[index - 1][:name] : nil
|
|
111
|
+
|
|
112
|
+
job step_name, step_worker,
|
|
113
|
+
needs: needs_job,
|
|
114
|
+
workers: step_workers,
|
|
115
|
+
condition: step_condition
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Validate and build in one step
|
|
122
|
+
#
|
|
123
|
+
# @return [Class] A new Workflow subclass
|
|
124
|
+
# @raise [ArgumentError] if the chain is invalid
|
|
125
|
+
def build!
|
|
126
|
+
validate!
|
|
127
|
+
build
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Validate the chain configuration
|
|
131
|
+
#
|
|
132
|
+
# @raise [ArgumentError] if validation fails
|
|
133
|
+
def validate!
|
|
134
|
+
if @name.nil? || @name.empty?
|
|
135
|
+
raise ArgumentError,
|
|
136
|
+
"Chain must have a name"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if @steps.empty?
|
|
140
|
+
raise ArgumentError,
|
|
141
|
+
"Chain must have at least one step"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check for duplicate step names
|
|
145
|
+
step_names = @steps.map { |s| s[:name] }
|
|
146
|
+
duplicates = step_names.select { |n| step_names.count(n) > 1 }.uniq
|
|
147
|
+
if duplicates.any?
|
|
148
|
+
raise ArgumentError,
|
|
149
|
+
"Duplicate step names: #{duplicates.join(', ')}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Validate workers
|
|
153
|
+
@steps.each do |step_config|
|
|
154
|
+
unless step_config[:worker]
|
|
155
|
+
raise ArgumentError,
|
|
156
|
+
"Step '#{step_config[:name]}' must specify a worker class"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
unless step_config[:worker] < Fractor::Worker
|
|
160
|
+
raise ArgumentError,
|
|
161
|
+
"Step '#{step_config[:name]}' worker must inherit from Fractor::Worker"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
true
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fractor
|
|
4
|
+
class Workflow
|
|
5
|
+
# Circuit breaker implementation for fault tolerance
|
|
6
|
+
#
|
|
7
|
+
# The circuit breaker has three states:
|
|
8
|
+
# - Closed: Normal operation, requests pass through
|
|
9
|
+
# - Open: Failure threshold exceeded, requests fail fast
|
|
10
|
+
# - Half-Open: Testing if service recovered, limited requests allowed
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# breaker = CircuitBreaker.new(threshold: 5, timeout: 60)
|
|
14
|
+
# breaker.call do
|
|
15
|
+
# # Risky operation
|
|
16
|
+
# end
|
|
17
|
+
class CircuitBreaker
|
|
18
|
+
# Circuit breaker states
|
|
19
|
+
STATE_CLOSED = :closed
|
|
20
|
+
STATE_OPEN = :open
|
|
21
|
+
STATE_HALF_OPEN = :half_open
|
|
22
|
+
|
|
23
|
+
attr_reader :state, :failure_count, :last_failure_time,
|
|
24
|
+
:threshold, :timeout, :half_open_calls
|
|
25
|
+
|
|
26
|
+
# Initialize a new circuit breaker
|
|
27
|
+
#
|
|
28
|
+
# @param threshold [Integer] Number of failures before opening circuit
|
|
29
|
+
# @param timeout [Integer] Seconds to wait before trying half-open
|
|
30
|
+
# @param half_open_calls [Integer] Number of test calls in half-open
|
|
31
|
+
def initialize(threshold: 5, timeout: 60, half_open_calls: 3)
|
|
32
|
+
@threshold = threshold
|
|
33
|
+
@timeout = timeout
|
|
34
|
+
@half_open_calls = half_open_calls
|
|
35
|
+
@state = STATE_CLOSED
|
|
36
|
+
@failure_count = 0
|
|
37
|
+
@success_count = 0
|
|
38
|
+
@last_failure_time = nil
|
|
39
|
+
@mutex = Mutex.new
|
|
40
|
+
@just_transitioned_to_half_open = false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Execute a block with circuit breaker protection
|
|
44
|
+
#
|
|
45
|
+
# @yield Block to execute
|
|
46
|
+
# @return [Object] Result of the block
|
|
47
|
+
# @raise [CircuitOpenError] If circuit is open
|
|
48
|
+
def call(&)
|
|
49
|
+
check_state
|
|
50
|
+
|
|
51
|
+
if open?
|
|
52
|
+
raise CircuitOpenError,
|
|
53
|
+
"Circuit breaker is open (#{failure_count} failures)"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
execute_with_breaker(&)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if circuit breaker is closed
|
|
60
|
+
#
|
|
61
|
+
# @return [Boolean] True if closed
|
|
62
|
+
def closed?
|
|
63
|
+
state == STATE_CLOSED
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check if circuit breaker is open
|
|
67
|
+
#
|
|
68
|
+
# @return [Boolean] True if open
|
|
69
|
+
def open?
|
|
70
|
+
state == STATE_OPEN
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if circuit breaker is half-open
|
|
74
|
+
#
|
|
75
|
+
# @return [Boolean] True if half-open
|
|
76
|
+
def half_open?
|
|
77
|
+
state == STATE_HALF_OPEN
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Reset the circuit breaker to closed state
|
|
81
|
+
def reset
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
@state = STATE_CLOSED
|
|
84
|
+
@failure_count = 0
|
|
85
|
+
@success_count = 0
|
|
86
|
+
@last_failure_time = nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Get circuit breaker statistics
|
|
91
|
+
#
|
|
92
|
+
# @return [Hash] Statistics including state, counts, and timing
|
|
93
|
+
def stats
|
|
94
|
+
{
|
|
95
|
+
state: state,
|
|
96
|
+
failure_count: failure_count,
|
|
97
|
+
success_count: @success_count,
|
|
98
|
+
last_failure_time: last_failure_time,
|
|
99
|
+
threshold: threshold,
|
|
100
|
+
timeout: timeout,
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Check and update circuit breaker state
|
|
107
|
+
def check_state
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
if open? && timeout_elapsed?
|
|
110
|
+
# Transition from open to half-open
|
|
111
|
+
@state = STATE_HALF_OPEN
|
|
112
|
+
@success_count = 0
|
|
113
|
+
@last_failure_time = nil # Clear to track new failures in half-open
|
|
114
|
+
@just_transitioned_to_half_open = true
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Execute block with circuit breaker logic
|
|
120
|
+
#
|
|
121
|
+
# @yield Block to execute
|
|
122
|
+
# @return [Object] Result of the block
|
|
123
|
+
def execute_with_breaker
|
|
124
|
+
result = yield
|
|
125
|
+
on_success
|
|
126
|
+
result
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
on_failure
|
|
129
|
+
raise e
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Handle successful execution
|
|
133
|
+
def on_success
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
if half_open?
|
|
136
|
+
@success_count += 1
|
|
137
|
+
if @success_count >= half_open_calls
|
|
138
|
+
# Transition from half-open to closed
|
|
139
|
+
@state = STATE_CLOSED
|
|
140
|
+
@failure_count = 0
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
# Reset failure count on success in closed state
|
|
144
|
+
@failure_count = 0
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Handle failed execution
|
|
150
|
+
def on_failure
|
|
151
|
+
@mutex.synchronize do
|
|
152
|
+
@failure_count += 1
|
|
153
|
+
@last_failure_time = Time.now
|
|
154
|
+
|
|
155
|
+
if half_open?
|
|
156
|
+
if @just_transitioned_to_half_open
|
|
157
|
+
# Just transitioned to half-open, stay there to allow recovery attempt
|
|
158
|
+
@just_transitioned_to_half_open = false
|
|
159
|
+
else
|
|
160
|
+
# Already in half-open and failed again, reopen circuit
|
|
161
|
+
@state = STATE_OPEN
|
|
162
|
+
end
|
|
163
|
+
elsif @failure_count >= threshold
|
|
164
|
+
# Threshold exceeded, open circuit
|
|
165
|
+
@state = STATE_OPEN
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Check if timeout has elapsed since last failure
|
|
171
|
+
#
|
|
172
|
+
# @return [Boolean] True if timeout elapsed
|
|
173
|
+
def timeout_elapsed?
|
|
174
|
+
return false unless last_failure_time
|
|
175
|
+
|
|
176
|
+
Time.now - last_failure_time >= timeout
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Error raised when circuit breaker is open
|
|
181
|
+
class CircuitOpenError < StandardError; end
|
|
182
|
+
end
|
|
183
|
+
end
|