fractor 0.1.6 → 0.1.7
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,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "retry_config"
|
|
4
|
+
|
|
5
|
+
module Fractor
|
|
6
|
+
class Workflow
|
|
7
|
+
# Represents a single job in a workflow.
|
|
8
|
+
# Jobs encapsulate worker configuration, dependencies, and input/output mappings.
|
|
9
|
+
class Job
|
|
10
|
+
attr_reader :name, :workflow_class, :worker_class, :dependencies,
|
|
11
|
+
:num_workers, :input_mappings, :condition_proc,
|
|
12
|
+
:terminates, :retry_config, :error_handlers, :fallback_job,
|
|
13
|
+
:circuit_breaker_config
|
|
14
|
+
|
|
15
|
+
def initialize(name, workflow_class)
|
|
16
|
+
@name = name
|
|
17
|
+
@workflow_class = workflow_class
|
|
18
|
+
@worker_class = nil
|
|
19
|
+
@dependencies = []
|
|
20
|
+
@num_workers = nil
|
|
21
|
+
@input_mappings = {}
|
|
22
|
+
@condition_proc = nil
|
|
23
|
+
@terminates = false
|
|
24
|
+
@outputs_to_workflow = false
|
|
25
|
+
@state = :pending
|
|
26
|
+
@retry_config = nil
|
|
27
|
+
@error_handlers = []
|
|
28
|
+
@fallback_job = nil
|
|
29
|
+
@circuit_breaker_config = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Specify which worker class processes this job.
|
|
33
|
+
#
|
|
34
|
+
# @param klass [Class] A Fractor::Worker subclass
|
|
35
|
+
def runs_with(klass)
|
|
36
|
+
unless klass < Fractor::Worker
|
|
37
|
+
raise ArgumentError, "#{klass} must inherit from Fractor::Worker"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@worker_class = klass
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Specify job dependencies.
|
|
44
|
+
#
|
|
45
|
+
# @param job_names [Array<String, Symbol>] Names of jobs this job depends on
|
|
46
|
+
def needs(*job_names)
|
|
47
|
+
@dependencies = job_names.flatten.map(&:to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Set the number of parallel workers for this job.
|
|
51
|
+
#
|
|
52
|
+
# @param n [Integer] Number of workers
|
|
53
|
+
def parallel_workers(n)
|
|
54
|
+
unless n.is_a?(Integer) && n.positive?
|
|
55
|
+
raise ArgumentError, "parallel_workers must be a positive integer"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
@num_workers = n
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Map inputs from the workflow input.
|
|
62
|
+
# Used when this is the first job in the workflow.
|
|
63
|
+
def inputs_from_workflow
|
|
64
|
+
@input_mappings[:workflow] = true
|
|
65
|
+
end
|
|
66
|
+
alias inputs_from :inputs_from_workflow
|
|
67
|
+
|
|
68
|
+
# Auto-wire inputs from dependencies if not explicitly configured.
|
|
69
|
+
# Called during workflow finalization.
|
|
70
|
+
def auto_wire_inputs!
|
|
71
|
+
return unless @input_mappings.empty?
|
|
72
|
+
|
|
73
|
+
if @dependencies.empty?
|
|
74
|
+
# No dependencies = must be a start job
|
|
75
|
+
@input_mappings[:workflow] = true
|
|
76
|
+
elsif @dependencies.size == 1
|
|
77
|
+
# Single dependency = auto-wire from that job
|
|
78
|
+
@input_mappings[@dependencies.first] = :all
|
|
79
|
+
end
|
|
80
|
+
# Multiple dependencies require explicit configuration
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Map inputs from a single upstream job.
|
|
84
|
+
#
|
|
85
|
+
# @param source_job [String, Symbol] The source job name
|
|
86
|
+
# @param select [Hash] Optional attribute mappings
|
|
87
|
+
def inputs_from_job(source_job, select: nil)
|
|
88
|
+
source = source_job.to_s
|
|
89
|
+
@input_mappings[source] = select || :all
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Map inputs from multiple upstream jobs.
|
|
93
|
+
#
|
|
94
|
+
# @param mappings [Hash] Hash of source_job => attribute_mappings
|
|
95
|
+
# Example:
|
|
96
|
+
# inputs_from_multiple(
|
|
97
|
+
# "job_a" => { validated_data: :validated_data },
|
|
98
|
+
# "job_b" => { analysis: :results }
|
|
99
|
+
# )
|
|
100
|
+
def inputs_from_multiple(mappings)
|
|
101
|
+
mappings.each do |source_job, attr_mappings|
|
|
102
|
+
source = source_job.to_s
|
|
103
|
+
@input_mappings[source] = attr_mappings
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Set a condition for this job to run.
|
|
108
|
+
#
|
|
109
|
+
# @param proc [Proc] A proc that receives the workflow context
|
|
110
|
+
def if_condition(proc)
|
|
111
|
+
unless proc.respond_to?(:call)
|
|
112
|
+
raise ArgumentError, "if_condition must be callable"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@condition_proc = proc
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Mark this job as a workflow terminator.
|
|
119
|
+
#
|
|
120
|
+
# @param value [Boolean] Whether this job terminates the workflow
|
|
121
|
+
def terminates_workflow(value = true)
|
|
122
|
+
@terminates = value
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Mark this job's outputs as mapping to workflow outputs.
|
|
126
|
+
def outputs_to_workflow
|
|
127
|
+
@outputs_to_workflow = true
|
|
128
|
+
end
|
|
129
|
+
alias outputs_to :outputs_to_workflow
|
|
130
|
+
|
|
131
|
+
# Configure retry behavior for this job.
|
|
132
|
+
#
|
|
133
|
+
# @param max_attempts [Integer] Maximum number of retry attempts
|
|
134
|
+
# @param backoff [Symbol] Backoff strategy (:exponential, :linear, :constant, :none)
|
|
135
|
+
# @param initial_delay [Numeric] Initial delay in seconds
|
|
136
|
+
# @param max_delay [Numeric] Maximum delay in seconds
|
|
137
|
+
# @param timeout [Numeric] Job execution timeout in seconds
|
|
138
|
+
# @param retryable_errors [Array<Class>] List of retryable error classes
|
|
139
|
+
# @param options [Hash] Additional retry options
|
|
140
|
+
def retry_on_error(max_attempts: 3, backoff: :exponential,
|
|
141
|
+
initial_delay: 1, max_delay: nil, timeout: nil,
|
|
142
|
+
retryable_errors: [StandardError], **options)
|
|
143
|
+
@retry_config = Workflow::RetryConfig.from_options(
|
|
144
|
+
max_attempts: max_attempts,
|
|
145
|
+
backoff: backoff,
|
|
146
|
+
initial_delay: initial_delay,
|
|
147
|
+
max_delay: max_delay,
|
|
148
|
+
timeout: timeout,
|
|
149
|
+
retryable_errors: retryable_errors,
|
|
150
|
+
**options,
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Add an error handler for this job.
|
|
155
|
+
#
|
|
156
|
+
# @param handler [Proc] A proc that receives (error, context)
|
|
157
|
+
def on_error(&handler)
|
|
158
|
+
unless handler.respond_to?(:call)
|
|
159
|
+
raise ArgumentError, "on_error must be given a block"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
@error_handlers << handler
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Set a fallback job for this job.
|
|
166
|
+
#
|
|
167
|
+
# @param job_name [String, Symbol] Name of the fallback job
|
|
168
|
+
def fallback_to(job_name)
|
|
169
|
+
@fallback_job = job_name.to_s
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Configure circuit breaker for this job.
|
|
173
|
+
#
|
|
174
|
+
# @param threshold [Integer] Number of failures before opening circuit
|
|
175
|
+
# @param timeout [Integer] Seconds to wait before trying half-open
|
|
176
|
+
# @param half_open_calls [Integer] Number of test calls in half-open
|
|
177
|
+
# @param shared_key [String] Optional key for shared circuit breaker
|
|
178
|
+
def circuit_breaker(threshold: 5, timeout: 60, half_open_calls: 3,
|
|
179
|
+
shared_key: nil)
|
|
180
|
+
@circuit_breaker_config = {
|
|
181
|
+
threshold: threshold,
|
|
182
|
+
timeout: timeout,
|
|
183
|
+
half_open_calls: half_open_calls,
|
|
184
|
+
shared_key: shared_key,
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Check if this job has circuit breaker configured.
|
|
189
|
+
#
|
|
190
|
+
# @return [Boolean] Whether circuit breaker is configured
|
|
191
|
+
def circuit_breaker_enabled?
|
|
192
|
+
!@circuit_breaker_config.nil?
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Get the circuit breaker key for this job.
|
|
196
|
+
#
|
|
197
|
+
# @return [String] The circuit breaker key
|
|
198
|
+
def circuit_breaker_key
|
|
199
|
+
return nil unless circuit_breaker_enabled?
|
|
200
|
+
|
|
201
|
+
@circuit_breaker_config[:shared_key] || "job_#{@name}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Check if this job has retry configured.
|
|
205
|
+
#
|
|
206
|
+
# @return [Boolean] Whether retry is configured
|
|
207
|
+
def retry_enabled?
|
|
208
|
+
!@retry_config.nil? && @retry_config.max_attempts > 1
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Get the timeout for this job.
|
|
212
|
+
#
|
|
213
|
+
# @return [Numeric, nil] Timeout in seconds or nil
|
|
214
|
+
def timeout
|
|
215
|
+
@retry_config&.timeout
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Execute error handlers for this job.
|
|
219
|
+
#
|
|
220
|
+
# @param error [Exception] The error that occurred
|
|
221
|
+
# @param context [WorkflowContext] The workflow context
|
|
222
|
+
def handle_error(error, context)
|
|
223
|
+
@error_handlers.each do |handler|
|
|
224
|
+
handler.call(error, context)
|
|
225
|
+
rescue StandardError => e
|
|
226
|
+
context.logger&.error(
|
|
227
|
+
"Error handler failed for job #{@name}: #{e.message}",
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Check if this job's outputs map to workflow outputs.
|
|
233
|
+
#
|
|
234
|
+
# @return [Boolean] Whether this job outputs to workflow
|
|
235
|
+
def outputs_to_workflow?
|
|
236
|
+
@outputs_to_workflow
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Check if this job should execute based on its condition.
|
|
240
|
+
#
|
|
241
|
+
# @param context [WorkflowContext] The workflow execution context
|
|
242
|
+
# @return [Boolean] Whether the job should execute
|
|
243
|
+
def should_execute?(context)
|
|
244
|
+
return true unless @condition_proc
|
|
245
|
+
|
|
246
|
+
@condition_proc.call(context)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Get the input type for this job from its worker.
|
|
250
|
+
#
|
|
251
|
+
# @return [Class] The input type class
|
|
252
|
+
def input_type
|
|
253
|
+
return nil unless @worker_class
|
|
254
|
+
|
|
255
|
+
@worker_class.input_type_class
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Get the output type for this job from its worker.
|
|
259
|
+
#
|
|
260
|
+
# @return [Class] The output type class
|
|
261
|
+
def output_type
|
|
262
|
+
return nil unless @worker_class
|
|
263
|
+
|
|
264
|
+
@worker_class.output_type_class
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Check if this job is ready to execute.
|
|
268
|
+
# A job is ready when all its dependencies have completed.
|
|
269
|
+
#
|
|
270
|
+
# @param completed_jobs [Set] Set of completed job names
|
|
271
|
+
# @return [Boolean] Whether the job is ready
|
|
272
|
+
def ready?(completed_jobs)
|
|
273
|
+
@dependencies.all? { |dep| completed_jobs.include?(dep) }
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Get or set the job state.
|
|
277
|
+
#
|
|
278
|
+
# @param new_state [Symbol] :pending, :ready, :running, :completed, :failed, :skipped
|
|
279
|
+
# @return [Symbol] The current state
|
|
280
|
+
def state(new_state = nil)
|
|
281
|
+
@state = new_state if new_state
|
|
282
|
+
@state
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def to_s
|
|
286
|
+
"Job[#{@name}]"
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fractor
|
|
4
|
+
class Workflow
|
|
5
|
+
# Validates job dependencies in workflows.
|
|
6
|
+
# Ensures dependencies exist and are acyclic.
|
|
7
|
+
#
|
|
8
|
+
# This validator prevents runtime errors by catching configuration
|
|
9
|
+
# issues before workflow execution begins.
|
|
10
|
+
class JobDependencyValidator
|
|
11
|
+
# Error raised when dependency validation fails.
|
|
12
|
+
class DependencyError < StandardError; end
|
|
13
|
+
|
|
14
|
+
def initialize(jobs)
|
|
15
|
+
@jobs = jobs
|
|
16
|
+
@jobs_by_name = build_jobs_index
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Validate all job dependencies.
|
|
20
|
+
# Raises DependencyError if any validation fails.
|
|
21
|
+
#
|
|
22
|
+
# @raise [DependencyError] if validation fails
|
|
23
|
+
# @return [true] if validation passes
|
|
24
|
+
def validate!
|
|
25
|
+
check_missing_dependencies
|
|
26
|
+
check_circular_dependencies
|
|
27
|
+
true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Check for circular dependencies using depth-first search.
|
|
31
|
+
#
|
|
32
|
+
# @raise [DependencyError] if circular dependencies found
|
|
33
|
+
# @return [true] if no circular dependencies
|
|
34
|
+
def check_circular_dependencies
|
|
35
|
+
visited = Set.new
|
|
36
|
+
path = [] # Track current path as an array
|
|
37
|
+
|
|
38
|
+
@jobs.each do |job|
|
|
39
|
+
next if visited.include?(job.name)
|
|
40
|
+
|
|
41
|
+
cycle = dfs_cycle_check(job, visited, path)
|
|
42
|
+
if cycle
|
|
43
|
+
cycle_path = cycle.join(" -> ")
|
|
44
|
+
raise DependencyError, "Circular dependency detected: #{cycle_path}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# DFS cycle detection that returns the cycle path if found.
|
|
52
|
+
#
|
|
53
|
+
# @param job [Job] The job to check
|
|
54
|
+
# @param visited [Set] Jobs already visited
|
|
55
|
+
# @param path [Array] Current path being explored
|
|
56
|
+
# @return [Array<String>, nil] Cycle path if found, nil otherwise
|
|
57
|
+
def dfs_cycle_check(job, visited, path)
|
|
58
|
+
return nil unless job
|
|
59
|
+
|
|
60
|
+
# If this job is in the current path, we found a cycle
|
|
61
|
+
if path.include?(job.name)
|
|
62
|
+
# Extract the cycle portion
|
|
63
|
+
cycle_start_index = path.index(job.name)
|
|
64
|
+
return path[cycle_start_index..] + [job.name]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# If we've already fully explored this job, no cycle from here
|
|
68
|
+
return nil if visited.include?(job.name)
|
|
69
|
+
|
|
70
|
+
# Add to current path
|
|
71
|
+
path << job.name
|
|
72
|
+
|
|
73
|
+
# Check all dependencies
|
|
74
|
+
job.dependencies.each do |dep_name|
|
|
75
|
+
dep_job = @jobs_by_name[dep_name]
|
|
76
|
+
next unless dep_job
|
|
77
|
+
|
|
78
|
+
cycle = dfs_cycle_check(dep_job, visited, path)
|
|
79
|
+
return cycle if cycle
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Remove from path and mark as visited
|
|
83
|
+
path.pop
|
|
84
|
+
visited.add(job.name)
|
|
85
|
+
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Check that all job dependencies exist.
|
|
90
|
+
#
|
|
91
|
+
# @raise [DependencyError] if any dependencies are missing
|
|
92
|
+
# @return [true] if all dependencies exist
|
|
93
|
+
def check_missing_dependencies
|
|
94
|
+
missing = []
|
|
95
|
+
|
|
96
|
+
@jobs.each do |job|
|
|
97
|
+
job.dependencies.each do |dep_name|
|
|
98
|
+
unless @jobs_by_name.key?(dep_name)
|
|
99
|
+
missing << "#{job.name} depends on non-existent job '#{dep_name}'"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return true if missing.empty?
|
|
105
|
+
|
|
106
|
+
raise DependencyError,
|
|
107
|
+
"Missing dependencies:\n - #{missing.join("\n - ")}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Build an index of jobs by name for quick lookup.
|
|
113
|
+
#
|
|
114
|
+
# @return [Hash<String, Job>]
|
|
115
|
+
def build_jobs_index
|
|
116
|
+
@jobs.each_with_object({}) { |job, hash| hash[job.name] = job }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Fractor
|
|
8
|
+
class Workflow
|
|
9
|
+
# Logger wrapper for workflow execution logging.
|
|
10
|
+
# Provides structured logging with correlation IDs and context.
|
|
11
|
+
class WorkflowLogger
|
|
12
|
+
attr_reader :logger, :correlation_id
|
|
13
|
+
|
|
14
|
+
def initialize(logger: nil, correlation_id: nil)
|
|
15
|
+
@logger = logger || default_logger
|
|
16
|
+
@correlation_id = correlation_id || generate_correlation_id
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Log an info message with structured context
|
|
20
|
+
def info(message, context = {})
|
|
21
|
+
log(:info, message, context)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Log a warning message with structured context
|
|
25
|
+
def warn(message, context = {})
|
|
26
|
+
log(:warn, message, context)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Log an error message with structured context
|
|
30
|
+
def error(message, context = {})
|
|
31
|
+
log(:error, message, context)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Log a debug message with structured context
|
|
35
|
+
def debug(message, context = {})
|
|
36
|
+
log(:debug, message, context)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Create a child logger with additional context
|
|
40
|
+
def child(additional_context = {})
|
|
41
|
+
child_logger = self.class.new(
|
|
42
|
+
logger: @logger,
|
|
43
|
+
correlation_id: @correlation_id,
|
|
44
|
+
)
|
|
45
|
+
child_logger.instance_variable_set(
|
|
46
|
+
:@base_context,
|
|
47
|
+
base_context.merge(additional_context),
|
|
48
|
+
)
|
|
49
|
+
child_logger
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def log(level, message, context)
|
|
55
|
+
return unless should_log?(level)
|
|
56
|
+
|
|
57
|
+
log_data = build_log_data(level, message, context)
|
|
58
|
+
formatted_message = format_message(log_data)
|
|
59
|
+
@logger.send(level, formatted_message)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def should_log?(level)
|
|
63
|
+
@logger.send("#{level}?")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_log_data(level, message, context)
|
|
67
|
+
{
|
|
68
|
+
timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ"),
|
|
69
|
+
level: level.to_s.upcase,
|
|
70
|
+
correlation_id: @correlation_id,
|
|
71
|
+
message: message,
|
|
72
|
+
}.merge(base_context).merge(context)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def format_message(log_data)
|
|
76
|
+
# Simple key=value format for readability
|
|
77
|
+
parts = log_data.map { |k, v| "#{k}=#{format_value(v)}" }
|
|
78
|
+
parts.join(" ")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def format_value(value)
|
|
82
|
+
case value
|
|
83
|
+
when String
|
|
84
|
+
value.include?(" ") ? "\"#{value}\"" : value
|
|
85
|
+
when Hash, Array
|
|
86
|
+
value.to_json
|
|
87
|
+
else
|
|
88
|
+
value.to_s
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def base_context
|
|
93
|
+
@base_context ||= {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def default_logger
|
|
97
|
+
Logger.new($stdout).tap do |log|
|
|
98
|
+
log.level = Logger::INFO
|
|
99
|
+
log.formatter = proc do |_severity, _datetime, _progname, msg|
|
|
100
|
+
"#{msg}\n"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def generate_correlation_id
|
|
106
|
+
"wf-#{SecureRandom.hex(8)}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fractor
|
|
4
|
+
class Workflow
|
|
5
|
+
# Manages pre-execution validation for workflows.
|
|
6
|
+
# Provides hooks for custom validation logic and detailed error messages.
|
|
7
|
+
#
|
|
8
|
+
# This class ensures workflows are validated with full context before
|
|
9
|
+
# execution begins, catching errors early with clear messages.
|
|
10
|
+
class PreExecutionContext
|
|
11
|
+
attr_reader :workflow, :input, :errors, :warnings
|
|
12
|
+
|
|
13
|
+
def initialize(workflow, input)
|
|
14
|
+
@workflow = workflow
|
|
15
|
+
@input = input
|
|
16
|
+
@errors = []
|
|
17
|
+
@warnings = []
|
|
18
|
+
@validation_hooks = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Register a custom validation hook.
|
|
22
|
+
# Hooks should return true if validation passes, or add error messages.
|
|
23
|
+
#
|
|
24
|
+
# @param name [String, Symbol] Optional name for the validation
|
|
25
|
+
# @yield [context] Block that receives the pre-execution context
|
|
26
|
+
#
|
|
27
|
+
# @example Add custom validation
|
|
28
|
+
# context.add_validation_hook(:check_api_key) do |ctx|
|
|
29
|
+
# unless ctx.input.api_key
|
|
30
|
+
# ctx.add_error("API key is required")
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
def add_validation_hook(name = nil, &block)
|
|
34
|
+
unless block
|
|
35
|
+
raise ArgumentError, "Must provide a block for validation hook"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@validation_hooks << { name: name, block: block }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Run all validations and return whether validation passed.
|
|
42
|
+
#
|
|
43
|
+
# @return [Boolean] true if all validations pass
|
|
44
|
+
# @raise [WorkflowError] if validation fails
|
|
45
|
+
def validate!
|
|
46
|
+
reset_results
|
|
47
|
+
|
|
48
|
+
# Run built-in validations
|
|
49
|
+
validate_workflow_definition!
|
|
50
|
+
validate_input_type!
|
|
51
|
+
validate_input_presence!
|
|
52
|
+
|
|
53
|
+
# Run custom validation hooks
|
|
54
|
+
run_validation_hooks
|
|
55
|
+
|
|
56
|
+
# Raise error if any validation failed
|
|
57
|
+
unless @errors.empty?
|
|
58
|
+
raise WorkflowError, validation_error_message
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Log warnings if any
|
|
62
|
+
log_warnings unless @warnings.empty?
|
|
63
|
+
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Add an error message to the validation context.
|
|
68
|
+
#
|
|
69
|
+
# @param message [String] The error message
|
|
70
|
+
def add_error(message)
|
|
71
|
+
@errors << message
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Add a warning message to the validation context.
|
|
75
|
+
#
|
|
76
|
+
# @param message [String] The warning message
|
|
77
|
+
def add_warning(message)
|
|
78
|
+
@warnings << message
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check if validation passed (errors only).
|
|
82
|
+
#
|
|
83
|
+
# @return [Boolean] true if no errors
|
|
84
|
+
def valid?
|
|
85
|
+
@errors.empty?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Check if there are any warnings (regardless of errors).
|
|
89
|
+
#
|
|
90
|
+
# @return [Boolean] true if warnings present
|
|
91
|
+
def has_warnings?
|
|
92
|
+
!@warnings.empty?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def reset_results
|
|
98
|
+
@errors = []
|
|
99
|
+
@warnings = []
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_workflow_definition!
|
|
103
|
+
# Ensure workflow is properly defined
|
|
104
|
+
workflow_class = @workflow.class
|
|
105
|
+
|
|
106
|
+
if workflow_class.jobs.empty?
|
|
107
|
+
add_error("Workflow '#{workflow_class.workflow_name}' has no jobs defined")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Check for start job in pipeline mode
|
|
111
|
+
if workflow_class.workflow_mode == :pipeline && !workflow_class.start_job_name
|
|
112
|
+
add_error("Pipeline workflow must define start_with")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def validate_input_type!
|
|
117
|
+
expected_type = @workflow.class.input_model_class
|
|
118
|
+
return unless expected_type
|
|
119
|
+
|
|
120
|
+
unless @input.is_a?(expected_type)
|
|
121
|
+
add_error(
|
|
122
|
+
"Workflow '#{@workflow.class.workflow_name}' expects input of type " \
|
|
123
|
+
"#{expected_type}, got #{@input.class}",
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_input_presence!
|
|
129
|
+
return if @input
|
|
130
|
+
|
|
131
|
+
# Check if workflow requires input
|
|
132
|
+
if @workflow.class.input_model_class || requires_workflow_input?
|
|
133
|
+
add_error(
|
|
134
|
+
"Workflow '#{@workflow.class.workflow_name}' requires input but none was provided",
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def requires_workflow_input?
|
|
140
|
+
# Check if any job takes input from workflow
|
|
141
|
+
@workflow.class.jobs.values.any? do |job|
|
|
142
|
+
job.input_mappings.key?(:workflow)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def run_validation_hooks
|
|
147
|
+
@validation_hooks.each do |hook|
|
|
148
|
+
hook[:block].call(self)
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
add_error(
|
|
151
|
+
"Validation hook '#{hook[:name] || 'unnamed'}' raised error: #{e.message}",
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validation_error_message
|
|
157
|
+
workflow_name = @workflow.class.workflow_name
|
|
158
|
+
|
|
159
|
+
lines = [
|
|
160
|
+
"Workflow '#{workflow_name}' validation failed",
|
|
161
|
+
"",
|
|
162
|
+
"Errors:",
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
@errors.each_with_index do |error, index|
|
|
166
|
+
lines << " #{index + 1}. #{error}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
unless @warnings.empty?
|
|
170
|
+
lines << ""
|
|
171
|
+
lines << "Warnings:"
|
|
172
|
+
@warnings.each_with_index do |warning, index|
|
|
173
|
+
lines << " #{index + 1}. #{warning}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
lines << ""
|
|
178
|
+
lines << "Fix: Address the errors above before executing the workflow."
|
|
179
|
+
|
|
180
|
+
lines.join("\n")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def log_warnings
|
|
184
|
+
workflow_name = @workflow.class.workflow_name
|
|
185
|
+
|
|
186
|
+
warn "Workflow '#{workflow_name}' validation warnings:"
|
|
187
|
+
@warnings.each_with_index do |warning, index|
|
|
188
|
+
warn " #{index + 1}. #{warning}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|