fractor 0.1.4 → 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-https---raw-githubusercontent-com-riboseinc-oss-guides-main-ci-rubocop-yml +552 -0
- data/.rubocop.yml +14 -8
- data/.rubocop_todo.yml +284 -43
- data/README.adoc +111 -950
- 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/auto_detection/auto_detection.rb +9 -9
- data/examples/continuous_chat_common/message_protocol.rb +53 -0
- data/examples/continuous_chat_fractor/README.adoc +217 -0
- data/examples/continuous_chat_fractor/chat_client.rb +303 -0
- data/examples/continuous_chat_fractor/chat_common.rb +83 -0
- data/examples/continuous_chat_fractor/chat_server.rb +167 -0
- data/examples/continuous_chat_fractor/simulate.rb +345 -0
- data/examples/continuous_chat_server/README.adoc +135 -0
- data/examples/continuous_chat_server/chat_client.rb +303 -0
- data/examples/continuous_chat_server/chat_server.rb +359 -0
- data/examples/continuous_chat_server/simulate.rb +343 -0
- 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/hierarchical_hasher/hierarchical_hasher.rb +12 -8
- 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/multi_work_type/multi_work_type.rb +30 -29
- data/examples/performance_monitoring.rb +120 -0
- data/examples/pipeline_processing/README.adoc +740 -26
- data/examples/pipeline_processing/pipeline_processing.rb +16 -16
- data/examples/priority_work_example.rb +155 -0
- data/examples/producer_subscriber/README.adoc +889 -46
- data/examples/producer_subscriber/producer_subscriber.rb +20 -16
- data/examples/scatter_gather/README.adoc +829 -27
- data/examples/scatter_gather/scatter_gather.rb +29 -28
- data/examples/simple/README.adoc +347 -0
- data/examples/simple/sample.rb +5 -5
- data/examples/specialized_workers/README.adoc +622 -26
- data/examples/specialized_workers/specialized_workers.rb +88 -45
- 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 +183 -0
- 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 +33 -1
- data/lib/fractor/shutdown_handler.rb +168 -0
- data/lib/fractor/signal_handler.rb +80 -0
- data/lib/fractor/supervisor.rb +430 -144
- 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 +88 -0
- data/lib/fractor/work_result.rb +181 -9
- data/lib/fractor/worker.rb +75 -1
- 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 -91
- data/lib/fractor/wrapped_ractor3.rb +161 -0
- data/lib/fractor/wrapped_ractor4.rb +242 -0
- data/lib/fractor.rb +93 -3
- metadata +192 -6
- data/tests/sample.rb.bak +0 -309
- data/tests/sample_working.rb.bak +0 -209
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "retry_strategy"
|
|
4
|
+
|
|
5
|
+
module Fractor
|
|
6
|
+
class Workflow
|
|
7
|
+
# Configuration for job retry behavior
|
|
8
|
+
class RetryConfig
|
|
9
|
+
attr_reader :strategy, :timeout, :retryable_errors
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
strategy: NoRetry.new,
|
|
13
|
+
timeout: nil,
|
|
14
|
+
retryable_errors: [StandardError]
|
|
15
|
+
)
|
|
16
|
+
@strategy = strategy
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
@retryable_errors = Array(retryable_errors)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Check if an error should trigger a retry
|
|
22
|
+
# @param error [Exception] The error to check
|
|
23
|
+
# @return [Boolean] true if error should be retried
|
|
24
|
+
def retryable?(error)
|
|
25
|
+
retryable_errors.any? { |err_class| error.is_a?(err_class) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get maximum number of retry attempts
|
|
29
|
+
# @return [Integer] Maximum attempts
|
|
30
|
+
def max_attempts
|
|
31
|
+
strategy.max_attempts
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Calculate delay for a given attempt
|
|
35
|
+
# @param attempt [Integer] The attempt number
|
|
36
|
+
# @return [Numeric] Delay in seconds
|
|
37
|
+
def delay_for(attempt)
|
|
38
|
+
strategy.delay_for(attempt)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Create a retry config from a hash of options
|
|
42
|
+
# @param options [Hash] Configuration options
|
|
43
|
+
# @option options [Symbol] :backoff Strategy type (:exponential, :linear, :constant, :none)
|
|
44
|
+
# @option options [Integer] :max_attempts Maximum retry attempts
|
|
45
|
+
# @option options [Numeric] :initial_delay Initial delay in seconds
|
|
46
|
+
# @option options [Numeric] :max_delay Maximum delay in seconds
|
|
47
|
+
# @option options [Numeric] :timeout Job timeout in seconds
|
|
48
|
+
# @option options [Array<Class>] :retryable_errors List of retryable error classes
|
|
49
|
+
# @return [RetryConfig] New retry configuration
|
|
50
|
+
def self.from_options(**options)
|
|
51
|
+
strategy = create_strategy(**options)
|
|
52
|
+
new(
|
|
53
|
+
strategy: strategy,
|
|
54
|
+
timeout: options[:timeout],
|
|
55
|
+
retryable_errors: options[:retryable_errors] || [StandardError],
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Create a retry strategy from options
|
|
60
|
+
# @param options [Hash] Strategy options
|
|
61
|
+
# @return [RetryStrategy] A retry strategy instance
|
|
62
|
+
def self.create_strategy(**options)
|
|
63
|
+
backoff = options[:backoff] || :exponential
|
|
64
|
+
max_attempts = options[:max_attempts] || 3
|
|
65
|
+
max_delay = options[:max_delay]
|
|
66
|
+
|
|
67
|
+
case backoff
|
|
68
|
+
when :exponential
|
|
69
|
+
ExponentialBackoff.new(
|
|
70
|
+
initial_delay: options[:initial_delay] || 1,
|
|
71
|
+
multiplier: options[:multiplier] || 2,
|
|
72
|
+
max_attempts: max_attempts,
|
|
73
|
+
max_delay: max_delay,
|
|
74
|
+
)
|
|
75
|
+
when :linear
|
|
76
|
+
LinearBackoff.new(
|
|
77
|
+
initial_delay: options[:initial_delay] || 1,
|
|
78
|
+
increment: options[:increment] || 1,
|
|
79
|
+
max_attempts: max_attempts,
|
|
80
|
+
max_delay: max_delay,
|
|
81
|
+
)
|
|
82
|
+
when :constant
|
|
83
|
+
ConstantDelay.new(
|
|
84
|
+
delay: options[:delay] || 1,
|
|
85
|
+
max_attempts: max_attempts,
|
|
86
|
+
max_delay: max_delay,
|
|
87
|
+
)
|
|
88
|
+
when :none, false
|
|
89
|
+
NoRetry.new
|
|
90
|
+
else
|
|
91
|
+
raise ArgumentError, "Unknown backoff strategy: #{backoff}"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Tracks retry state for a job execution
|
|
97
|
+
class RetryState
|
|
98
|
+
attr_reader :job_name, :attempt, :errors, :started_at
|
|
99
|
+
|
|
100
|
+
def initialize(job_name)
|
|
101
|
+
@job_name = job_name
|
|
102
|
+
@attempt = 1
|
|
103
|
+
@errors = []
|
|
104
|
+
@started_at = Time.now
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Record a failed attempt
|
|
108
|
+
# @param error [Exception] The error that occurred
|
|
109
|
+
def record_failure(error)
|
|
110
|
+
@errors << {
|
|
111
|
+
attempt: @attempt,
|
|
112
|
+
error: error,
|
|
113
|
+
timestamp: Time.now,
|
|
114
|
+
}
|
|
115
|
+
@attempt += 1
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Check if retry attempts have been exhausted
|
|
119
|
+
# @param max_attempts [Integer] Maximum allowed attempts
|
|
120
|
+
# @return [Boolean] true if attempts exhausted
|
|
121
|
+
def exhausted?(max_attempts)
|
|
122
|
+
@attempt > max_attempts
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Get the last error that occurred
|
|
126
|
+
# @return [Exception, nil] The last error or nil
|
|
127
|
+
def last_error
|
|
128
|
+
@errors.last&.dig(:error)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Get total execution time across all attempts
|
|
132
|
+
# @return [Numeric] Total time in seconds
|
|
133
|
+
def total_time
|
|
134
|
+
Time.now - @started_at
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Get a summary of all retry attempts
|
|
138
|
+
# @return [Hash] Summary data
|
|
139
|
+
def summary
|
|
140
|
+
{
|
|
141
|
+
job_name: @job_name,
|
|
142
|
+
total_attempts: @attempt - 1,
|
|
143
|
+
total_time: total_time,
|
|
144
|
+
errors: @errors.map do |err|
|
|
145
|
+
{
|
|
146
|
+
attempt: err[:attempt],
|
|
147
|
+
error_class: err[:error].class.name,
|
|
148
|
+
error_message: err[:error].message,
|
|
149
|
+
timestamp: err[:timestamp],
|
|
150
|
+
}
|
|
151
|
+
end,
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "retry_config"
|
|
4
|
+
|
|
5
|
+
module Fractor
|
|
6
|
+
class Workflow
|
|
7
|
+
# Orchestrates retry logic for workflow job execution.
|
|
8
|
+
# Handles retry strategies, backoff calculations, and attempt tracking.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# config = RetryConfig.from_options(backoff: :exponential, max_attempts: 3)
|
|
12
|
+
# orchestrator = RetryOrchestrator.new(config, debug: true)
|
|
13
|
+
# result = orchestrator.execute_with_retry(job) { |job| execute_job(job) }
|
|
14
|
+
class RetryOrchestrator
|
|
15
|
+
attr_reader :retry_config, :debug, :attempts
|
|
16
|
+
|
|
17
|
+
# Initialize a new retry orchestrator.
|
|
18
|
+
#
|
|
19
|
+
# @param retry_config [RetryConfig] The retry configuration
|
|
20
|
+
# @param debug [Boolean] Whether to enable debug logging
|
|
21
|
+
def initialize(retry_config, debug: false)
|
|
22
|
+
@retry_config = retry_config
|
|
23
|
+
@debug = debug
|
|
24
|
+
@attempts = 0
|
|
25
|
+
@last_error = nil
|
|
26
|
+
@all_errors = []
|
|
27
|
+
@started_at = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Execute a job with retry logic.
|
|
31
|
+
# Retries the job execution according to the retry strategy configuration.
|
|
32
|
+
#
|
|
33
|
+
# @param job [Job] The job to execute
|
|
34
|
+
# @yield [Job] Block that executes the job
|
|
35
|
+
# @return [Object] The execution result
|
|
36
|
+
# @raise [StandardError] If all retries are exhausted
|
|
37
|
+
def execute_with_retry(job)
|
|
38
|
+
reset!
|
|
39
|
+
|
|
40
|
+
@started_at = Time.now
|
|
41
|
+
|
|
42
|
+
loop do
|
|
43
|
+
@attempts += 1
|
|
44
|
+
|
|
45
|
+
log_debug "Executing job '#{job.name}', attempt #{@attempts}"
|
|
46
|
+
|
|
47
|
+
result = yield job
|
|
48
|
+
|
|
49
|
+
# If we got here without error, execution succeeded
|
|
50
|
+
log_retry_success(job) if @attempts > 1
|
|
51
|
+
return result
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
@last_error = e
|
|
54
|
+
|
|
55
|
+
# Track all errors for DLQ entry
|
|
56
|
+
@all_errors << {
|
|
57
|
+
attempt: @attempts,
|
|
58
|
+
error: e,
|
|
59
|
+
timestamp: Time.now,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Check if error is retryable
|
|
63
|
+
unless @retry_config.retryable?(e)
|
|
64
|
+
log_debug "Error #{e.class} is not retryable, failing immediately"
|
|
65
|
+
raise e
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Record the failure and check if we've exhausted retries
|
|
69
|
+
if exhausted?(@retry_config.max_attempts)
|
|
70
|
+
log_retry_exhausted(job)
|
|
71
|
+
raise e
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Calculate delay for this attempt
|
|
75
|
+
delay = calculate_delay(@attempts)
|
|
76
|
+
|
|
77
|
+
# Log retry attempt
|
|
78
|
+
log_retry_attempt(job, delay)
|
|
79
|
+
|
|
80
|
+
# Wait before retrying
|
|
81
|
+
sleep(delay) if delay.positive?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Check if a retry should be attempted.
|
|
86
|
+
#
|
|
87
|
+
# @param attempt [Integer] The current attempt number
|
|
88
|
+
# @param error [Exception] The error that occurred
|
|
89
|
+
# @return [Boolean] true if retry should be attempted
|
|
90
|
+
def should_retry?(_attempt, error)
|
|
91
|
+
return false if exhausted?(@retry_config.max_attempts)
|
|
92
|
+
|
|
93
|
+
@retry_config.retryable?(error)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Calculate the delay before the next retry attempt.
|
|
97
|
+
#
|
|
98
|
+
# @param attempt [Integer] The attempt number
|
|
99
|
+
# @return [Numeric] The delay in seconds
|
|
100
|
+
def calculate_delay(attempt)
|
|
101
|
+
@retry_config.delay_for(attempt)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get the last error that occurred during retry.
|
|
105
|
+
#
|
|
106
|
+
# @return [Exception, nil] The last error or nil
|
|
107
|
+
def last_error
|
|
108
|
+
@last_error
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Check if all retry attempts are exhausted.
|
|
112
|
+
#
|
|
113
|
+
# @param max_attempts [Integer] Maximum number of attempts
|
|
114
|
+
# @return [Boolean] true if retries are exhausted
|
|
115
|
+
def exhausted?(max_attempts)
|
|
116
|
+
@attempts >= max_attempts
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Reset the attempt counter and state.
|
|
120
|
+
def reset!
|
|
121
|
+
@attempts = 0
|
|
122
|
+
@last_error = nil
|
|
123
|
+
@all_errors = []
|
|
124
|
+
@started_at = nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Get the current retry state information.
|
|
128
|
+
#
|
|
129
|
+
# @return [Hash] Retry state details
|
|
130
|
+
def state
|
|
131
|
+
{
|
|
132
|
+
attempts: @attempts,
|
|
133
|
+
max_attempts: @retry_config.max_attempts,
|
|
134
|
+
last_error: @last_error&.class&.name,
|
|
135
|
+
exhausted: exhausted?(@retry_config.max_attempts),
|
|
136
|
+
all_errors: @all_errors.map do |err|
|
|
137
|
+
{
|
|
138
|
+
attempt: err[:attempt],
|
|
139
|
+
error_class: err[:error].class.name,
|
|
140
|
+
error_message: err[:error].message,
|
|
141
|
+
timestamp: err[:timestamp],
|
|
142
|
+
}
|
|
143
|
+
end,
|
|
144
|
+
total_time: @started_at ? Time.now - @started_at : 0,
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# Log a successful retry.
|
|
151
|
+
#
|
|
152
|
+
# @param job [Job] The job that succeeded
|
|
153
|
+
def log_retry_success(job)
|
|
154
|
+
puts "[RetryOrchestrator] Job '#{job.name}' succeeded on attempt #{@attempts}" if @debug
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Log that retries are exhausted.
|
|
158
|
+
#
|
|
159
|
+
# @param job [Job] The job that failed
|
|
160
|
+
def log_retry_exhausted(job)
|
|
161
|
+
puts "[RetryOrchestrator] Job '#{job.name}' retries exhausted after #{@attempts} attempts" if @debug
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Log a retry attempt.
|
|
165
|
+
#
|
|
166
|
+
# @param job [Job] The job being retried
|
|
167
|
+
# @param delay [Numeric] The delay before next attempt
|
|
168
|
+
def log_retry_attempt(job, delay)
|
|
169
|
+
message = "[RetryOrchestrator] Retrying job '#{job.name}' (attempt #{@attempts + 1}"
|
|
170
|
+
message += " after #{delay}s delay" if delay.positive?
|
|
171
|
+
message += ")"
|
|
172
|
+
|
|
173
|
+
puts message if @debug
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Log a debug message.
|
|
177
|
+
#
|
|
178
|
+
# @param message [String] The message to log
|
|
179
|
+
def log_debug(message)
|
|
180
|
+
puts "[RetryOrchestrator] #{message}" if @debug
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fractor
|
|
4
|
+
class Workflow
|
|
5
|
+
# Base class for retry strategies
|
|
6
|
+
class RetryStrategy
|
|
7
|
+
attr_reader :max_attempts, :max_delay
|
|
8
|
+
|
|
9
|
+
def initialize(max_attempts: 3, max_delay: nil)
|
|
10
|
+
@max_attempts = max_attempts
|
|
11
|
+
@max_delay = max_delay
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Calculate delay for the given attempt number
|
|
15
|
+
# @param attempt [Integer] The attempt number (1-based)
|
|
16
|
+
# @return [Numeric] Delay in seconds
|
|
17
|
+
def delay_for(attempt)
|
|
18
|
+
raise NotImplementedError, "Subclasses must implement delay_for"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
protected
|
|
22
|
+
|
|
23
|
+
def cap_delay(delay)
|
|
24
|
+
return delay unless max_delay
|
|
25
|
+
|
|
26
|
+
[delay, max_delay].min
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Exponential backoff retry strategy
|
|
31
|
+
class ExponentialBackoff < RetryStrategy
|
|
32
|
+
attr_reader :initial_delay, :multiplier
|
|
33
|
+
|
|
34
|
+
def initialize(initial_delay: 1, multiplier: 2, **options)
|
|
35
|
+
super(**options)
|
|
36
|
+
@initial_delay = initial_delay
|
|
37
|
+
@multiplier = multiplier
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def delay_for(attempt)
|
|
41
|
+
return 0 if attempt <= 1
|
|
42
|
+
|
|
43
|
+
delay = initial_delay * (multiplier**(attempt - 2))
|
|
44
|
+
cap_delay(delay)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Linear backoff retry strategy
|
|
49
|
+
class LinearBackoff < RetryStrategy
|
|
50
|
+
attr_reader :initial_delay, :increment
|
|
51
|
+
|
|
52
|
+
def initialize(initial_delay: 1, increment: 1, **options)
|
|
53
|
+
super(**options)
|
|
54
|
+
@initial_delay = initial_delay
|
|
55
|
+
@increment = increment
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def delay_for(attempt)
|
|
59
|
+
return 0 if attempt <= 1
|
|
60
|
+
|
|
61
|
+
delay = initial_delay + (increment * (attempt - 2))
|
|
62
|
+
cap_delay(delay)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Constant delay retry strategy
|
|
67
|
+
class ConstantDelay < RetryStrategy
|
|
68
|
+
attr_reader :delay
|
|
69
|
+
|
|
70
|
+
def initialize(delay: 1, **options)
|
|
71
|
+
super(**options)
|
|
72
|
+
@delay = delay
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def delay_for(attempt)
|
|
76
|
+
return 0 if attempt <= 1
|
|
77
|
+
|
|
78
|
+
cap_delay(delay)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# No retry strategy
|
|
83
|
+
class NoRetry < RetryStrategy
|
|
84
|
+
def initialize
|
|
85
|
+
super(max_attempts: 1)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def delay_for(_attempt)
|
|
89
|
+
0
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Fractor
|
|
7
|
+
class Workflow
|
|
8
|
+
# Structured logger that outputs JSON-formatted logs.
|
|
9
|
+
# Useful for log aggregation systems like ELK, Splunk, CloudWatch, etc.
|
|
10
|
+
class StructuredLogger < WorkflowLogger
|
|
11
|
+
def initialize(logger: nil, correlation_id: nil, format: :json)
|
|
12
|
+
super(logger: logger, correlation_id: correlation_id)
|
|
13
|
+
@format = format
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def format_message(log_data)
|
|
19
|
+
case @format
|
|
20
|
+
when :json
|
|
21
|
+
log_data.to_json
|
|
22
|
+
when :pretty_json
|
|
23
|
+
JSON.pretty_generate(log_data)
|
|
24
|
+
else
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fractor
|
|
4
|
+
class Workflow
|
|
5
|
+
# Validates type compatibility between jobs in workflows.
|
|
6
|
+
# Ensures input/output types are properly declared and compatible.
|
|
7
|
+
#
|
|
8
|
+
# This validator helps catch type mismatches before workflow execution.
|
|
9
|
+
class TypeCompatibilityValidator
|
|
10
|
+
# Error raised when type validation fails.
|
|
11
|
+
class TypeError < StandardError; end
|
|
12
|
+
|
|
13
|
+
def initialize(jobs)
|
|
14
|
+
@jobs = jobs
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Validate all job type declarations.
|
|
18
|
+
# Raises TypeError if any validation fails.
|
|
19
|
+
#
|
|
20
|
+
# @raise [TypeError] if validation fails
|
|
21
|
+
# @return [true] if validation passes
|
|
22
|
+
def validate!
|
|
23
|
+
@jobs.each do |job|
|
|
24
|
+
check_job_compatibility(job)
|
|
25
|
+
end
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Check that a job's type declarations are valid.
|
|
30
|
+
#
|
|
31
|
+
# @param job [Job] The job to check
|
|
32
|
+
# @raise [TypeError] if job has invalid type declarations
|
|
33
|
+
# @return [true] if job is valid
|
|
34
|
+
def check_job_compatibility(job)
|
|
35
|
+
# Check if worker has input type declared
|
|
36
|
+
if job.input_type
|
|
37
|
+
check_type_declaration(job, :input, job.input_type)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check if worker has output type declared
|
|
41
|
+
if job.output_type
|
|
42
|
+
check_type_declaration(job, :output, job.output_type)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Check that a type declaration is valid.
|
|
49
|
+
#
|
|
50
|
+
# @param job [Job] The job with the type declaration
|
|
51
|
+
# @param direction [Symbol] :input or :output
|
|
52
|
+
# @param type [Class] The type class to validate
|
|
53
|
+
# @raise [TypeError] if type declaration is invalid
|
|
54
|
+
# @return [true] if type is valid
|
|
55
|
+
def check_type_declaration(job, direction, type)
|
|
56
|
+
# Check if type is a class
|
|
57
|
+
unless type.is_a?(Class)
|
|
58
|
+
raise TypeError, type_declaration_error(job, direction,
|
|
59
|
+
"#{type.inspect} is not a class",
|
|
60
|
+
"Use a class like String or Integer")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Check if type is not Object (too generic)
|
|
64
|
+
if type == Object
|
|
65
|
+
warn "Job '#{job.name}' has #{direction}_type Object, which is too generic. " \
|
|
66
|
+
"Consider using a more specific type for better validation."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Check if type is BasicObject (even more generic)
|
|
70
|
+
if type == BasicObject
|
|
71
|
+
raise TypeError, type_declaration_error(job, direction,
|
|
72
|
+
"#{type} is too generic to be useful",
|
|
73
|
+
"Use a specific class like String or Hash")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Check type compatibility between connected jobs.
|
|
80
|
+
# Validates that output type of producer matches input type of consumer.
|
|
81
|
+
# Skips jobs with multiple dependencies (using inputs_from_multiple).
|
|
82
|
+
# Skips jobs using inputs_from_workflow (they use workflow input, not dependency output).
|
|
83
|
+
#
|
|
84
|
+
# @return [Hash] Compatibility report with any issues found
|
|
85
|
+
def check_compatibility_between_jobs
|
|
86
|
+
issues = []
|
|
87
|
+
|
|
88
|
+
@jobs.each do |consumer_job|
|
|
89
|
+
# Skip type checking for jobs with multiple dependencies
|
|
90
|
+
# These jobs use inputs_from_multiple to explicitly map outputs
|
|
91
|
+
next if consumer_job.dependencies.size > 1
|
|
92
|
+
|
|
93
|
+
# Skip type checking for jobs using inputs_from_workflow
|
|
94
|
+
# These jobs use the workflow's input type, not their dependency's output type
|
|
95
|
+
next if consumer_job.input_mappings.key?(:workflow)
|
|
96
|
+
|
|
97
|
+
consumer_job.dependencies.each do |producer_name|
|
|
98
|
+
producer_job = find_job(producer_name)
|
|
99
|
+
next unless producer_job
|
|
100
|
+
|
|
101
|
+
# Check if both have type declarations
|
|
102
|
+
# Check if types are compatible
|
|
103
|
+
if producer_job.output_type && consumer_job.input_type && !types_compatible?(producer_job.output_type,
|
|
104
|
+
consumer_job.input_type)
|
|
105
|
+
issues << {
|
|
106
|
+
producer: producer_job.name,
|
|
107
|
+
consumer: consumer_job.name,
|
|
108
|
+
producer_type: producer_job.output_type,
|
|
109
|
+
consumer_type: consumer_job.input_type,
|
|
110
|
+
suggestion: suggest_type_fix(producer_job.output_type,
|
|
111
|
+
consumer_job.input_type),
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
issues
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
# Find a job by name.
|
|
123
|
+
#
|
|
124
|
+
# @param name [String] Job name
|
|
125
|
+
# @return [Job, nil] The job or nil if not found
|
|
126
|
+
def find_job(name)
|
|
127
|
+
@jobs.find { |j| j.name == name }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Check if two types are compatible.
|
|
131
|
+
# For now, we use a simple check: output type should be a subclass of input type
|
|
132
|
+
# or they should be the same class.
|
|
133
|
+
#
|
|
134
|
+
# @param output_type [Class] The producer's output type
|
|
135
|
+
# @param input_type [Class] The consumer's input type
|
|
136
|
+
# @return [Boolean] true if types are compatible
|
|
137
|
+
def types_compatible?(output_type, input_type)
|
|
138
|
+
# Same type is always compatible
|
|
139
|
+
return true if output_type == input_type
|
|
140
|
+
|
|
141
|
+
# Output type is a subclass of input type (covariance)
|
|
142
|
+
return true if output_type < input_type
|
|
143
|
+
|
|
144
|
+
# Input type is Object (accepts anything)
|
|
145
|
+
return true if input_type == Object
|
|
146
|
+
|
|
147
|
+
# Special case: Numeric and Integer/Float are compatible
|
|
148
|
+
return true if numeric_compatibility?(output_type, input_type)
|
|
149
|
+
|
|
150
|
+
false
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Check for numeric type compatibility.
|
|
154
|
+
#
|
|
155
|
+
# @param output_type [Class] The producer's output type
|
|
156
|
+
# @param input_type [Class] The consumer's input type
|
|
157
|
+
# @return [Boolean] true if numerically compatible
|
|
158
|
+
def numeric_compatibility?(output_type, input_type)
|
|
159
|
+
# Integer is compatible with Numeric
|
|
160
|
+
return true if output_type == Integer && input_type == Numeric
|
|
161
|
+
|
|
162
|
+
# Float is compatible with Numeric
|
|
163
|
+
return true if output_type == Float && input_type == Numeric
|
|
164
|
+
|
|
165
|
+
false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Suggest a fix for type incompatibility.
|
|
169
|
+
#
|
|
170
|
+
# @param output_type [Class] The producer's output type
|
|
171
|
+
# @param input_type [Class] The consumer's input type
|
|
172
|
+
# @return [String] Suggestion message
|
|
173
|
+
def suggest_type_fix(output_type, input_type)
|
|
174
|
+
# Find common ancestor
|
|
175
|
+
common_ancestor = find_common_ancestor(output_type, input_type)
|
|
176
|
+
|
|
177
|
+
if common_ancestor
|
|
178
|
+
"Consider using #{common_ancestor.name} as the input type for the consumer, " \
|
|
179
|
+
"or ensure the producer outputs #{input_type.name} instead of #{output_type.name}"
|
|
180
|
+
else
|
|
181
|
+
"The producer's output type (#{output_type.name}) is not compatible with " \
|
|
182
|
+
"the consumer's input type (#{input_type.name}). " \
|
|
183
|
+
"Ensure the producer outputs data that the consumer can process."
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Find the common ancestor class of two types.
|
|
188
|
+
#
|
|
189
|
+
# @param type1 [Class] First type
|
|
190
|
+
# @param type2 [Class] Second type
|
|
191
|
+
# @return [Class, nil] Common ancestor class or nil
|
|
192
|
+
def find_common_ancestor(type1, type2)
|
|
193
|
+
return type1 if type2 == Object
|
|
194
|
+
return type2 if type1 == Object
|
|
195
|
+
|
|
196
|
+
# Get ancestry chains
|
|
197
|
+
type1_ancestors = type1.ancestors
|
|
198
|
+
type2_ancestors = type2.ancestors
|
|
199
|
+
|
|
200
|
+
# Find common ancestor
|
|
201
|
+
type1_ancestors.each do |ancestor|
|
|
202
|
+
return ancestor if type2_ancestors.include?(ancestor)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
nil
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Build a formatted error message for type declaration issues.
|
|
209
|
+
#
|
|
210
|
+
# @param job [Job] The job with the issue
|
|
211
|
+
# @param direction [Symbol] :input or :output
|
|
212
|
+
# @param problem [String] Description of the problem
|
|
213
|
+
# @param suggestion [String] Suggestion for fixing
|
|
214
|
+
# @return [String] Formatted error message
|
|
215
|
+
def type_declaration_error(job, direction, problem, suggestion)
|
|
216
|
+
"Job '#{job.name}' has invalid #{direction}_type declaration:\n" \
|
|
217
|
+
" Problem: #{problem}\n" \
|
|
218
|
+
" Suggestion: #{suggestion}"
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|