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.
Files changed (172) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +227 -102
  3. data/README.adoc +113 -1940
  4. data/docs/.lycheeignore +16 -0
  5. data/docs/Gemfile +24 -0
  6. data/docs/README.md +157 -0
  7. data/docs/_config.yml +151 -0
  8. data/docs/_features/error-handling.adoc +1192 -0
  9. data/docs/_features/index.adoc +80 -0
  10. data/docs/_features/monitoring.adoc +589 -0
  11. data/docs/_features/signal-handling.adoc +202 -0
  12. data/docs/_features/workflows.adoc +1235 -0
  13. data/docs/_guides/continuous-mode.adoc +736 -0
  14. data/docs/_guides/cookbook.adoc +1133 -0
  15. data/docs/_guides/index.adoc +55 -0
  16. data/docs/_guides/pipeline-mode.adoc +730 -0
  17. data/docs/_guides/troubleshooting.adoc +358 -0
  18. data/docs/_pages/architecture.adoc +1390 -0
  19. data/docs/_pages/core-concepts.adoc +1392 -0
  20. data/docs/_pages/design-principles.adoc +862 -0
  21. data/docs/_pages/getting-started.adoc +290 -0
  22. data/docs/_pages/installation.adoc +143 -0
  23. data/docs/_reference/api.adoc +1080 -0
  24. data/docs/_reference/error-reporting.adoc +670 -0
  25. data/docs/_reference/examples.adoc +181 -0
  26. data/docs/_reference/index.adoc +96 -0
  27. data/docs/_reference/troubleshooting.adoc +862 -0
  28. data/docs/_tutorials/complex-workflows.adoc +1022 -0
  29. data/docs/_tutorials/data-processing-pipeline.adoc +740 -0
  30. data/docs/_tutorials/first-application.adoc +384 -0
  31. data/docs/_tutorials/index.adoc +48 -0
  32. data/docs/_tutorials/long-running-services.adoc +931 -0
  33. data/docs/assets/images/favicon-16.png +0 -0
  34. data/docs/assets/images/favicon-32.png +0 -0
  35. data/docs/assets/images/favicon-48.png +0 -0
  36. data/docs/assets/images/favicon.ico +0 -0
  37. data/docs/assets/images/favicon.png +0 -0
  38. data/docs/assets/images/favicon.svg +45 -0
  39. data/docs/assets/images/fractor-icon.svg +49 -0
  40. data/docs/assets/images/fractor-logo.svg +61 -0
  41. data/docs/index.adoc +131 -0
  42. data/docs/lychee.toml +39 -0
  43. data/examples/api_aggregator/README.adoc +627 -0
  44. data/examples/api_aggregator/api_aggregator.rb +376 -0
  45. data/examples/auto_detection/README.adoc +407 -29
  46. data/examples/continuous_chat_common/message_protocol.rb +1 -1
  47. data/examples/error_reporting.rb +207 -0
  48. data/examples/file_processor/README.adoc +170 -0
  49. data/examples/file_processor/file_processor.rb +615 -0
  50. data/examples/file_processor/sample_files/invalid.csv +1 -0
  51. data/examples/file_processor/sample_files/orders.xml +24 -0
  52. data/examples/file_processor/sample_files/products.json +23 -0
  53. data/examples/file_processor/sample_files/users.csv +6 -0
  54. data/examples/hierarchical_hasher/README.adoc +629 -41
  55. data/examples/image_processor/README.adoc +610 -0
  56. data/examples/image_processor/image_processor.rb +349 -0
  57. data/examples/image_processor/processed_images/sample_10_processed.jpg.json +12 -0
  58. data/examples/image_processor/processed_images/sample_1_processed.jpg.json +12 -0
  59. data/examples/image_processor/processed_images/sample_2_processed.jpg.json +12 -0
  60. data/examples/image_processor/processed_images/sample_3_processed.jpg.json +12 -0
  61. data/examples/image_processor/processed_images/sample_4_processed.jpg.json +12 -0
  62. data/examples/image_processor/processed_images/sample_5_processed.jpg.json +12 -0
  63. data/examples/image_processor/processed_images/sample_6_processed.jpg.json +12 -0
  64. data/examples/image_processor/processed_images/sample_7_processed.jpg.json +12 -0
  65. data/examples/image_processor/processed_images/sample_8_processed.jpg.json +12 -0
  66. data/examples/image_processor/processed_images/sample_9_processed.jpg.json +12 -0
  67. data/examples/image_processor/test_images/sample_1.png +1 -0
  68. data/examples/image_processor/test_images/sample_10.png +1 -0
  69. data/examples/image_processor/test_images/sample_2.png +1 -0
  70. data/examples/image_processor/test_images/sample_3.png +1 -0
  71. data/examples/image_processor/test_images/sample_4.png +1 -0
  72. data/examples/image_processor/test_images/sample_5.png +1 -0
  73. data/examples/image_processor/test_images/sample_6.png +1 -0
  74. data/examples/image_processor/test_images/sample_7.png +1 -0
  75. data/examples/image_processor/test_images/sample_8.png +1 -0
  76. data/examples/image_processor/test_images/sample_9.png +1 -0
  77. data/examples/log_analyzer/README.adoc +662 -0
  78. data/examples/log_analyzer/log_analyzer.rb +579 -0
  79. data/examples/log_analyzer/sample_logs/apache.log +20 -0
  80. data/examples/log_analyzer/sample_logs/json.log +15 -0
  81. data/examples/log_analyzer/sample_logs/nginx.log +15 -0
  82. data/examples/log_analyzer/sample_logs/rails.log +29 -0
  83. data/examples/multi_work_type/README.adoc +576 -26
  84. data/examples/performance_monitoring.rb +120 -0
  85. data/examples/pipeline_processing/README.adoc +740 -26
  86. data/examples/pipeline_processing/pipeline_processing.rb +2 -2
  87. data/examples/priority_work_example.rb +155 -0
  88. data/examples/producer_subscriber/README.adoc +889 -46
  89. data/examples/scatter_gather/README.adoc +829 -27
  90. data/examples/simple/README.adoc +347 -0
  91. data/examples/specialized_workers/README.adoc +622 -26
  92. data/examples/specialized_workers/specialized_workers.rb +44 -8
  93. data/examples/stream_processor/README.adoc +206 -0
  94. data/examples/stream_processor/stream_processor.rb +284 -0
  95. data/examples/web_scraper/README.adoc +625 -0
  96. data/examples/web_scraper/web_scraper.rb +285 -0
  97. data/examples/workflow/README.adoc +406 -0
  98. data/examples/workflow/circuit_breaker/README.adoc +360 -0
  99. data/examples/workflow/circuit_breaker/circuit_breaker_workflow.rb +225 -0
  100. data/examples/workflow/conditional/README.adoc +483 -0
  101. data/examples/workflow/conditional/conditional_workflow.rb +215 -0
  102. data/examples/workflow/dead_letter_queue/README.adoc +374 -0
  103. data/examples/workflow/dead_letter_queue/dead_letter_queue_workflow.rb +217 -0
  104. data/examples/workflow/fan_out/README.adoc +381 -0
  105. data/examples/workflow/fan_out/fan_out_workflow.rb +202 -0
  106. data/examples/workflow/retry/README.adoc +248 -0
  107. data/examples/workflow/retry/retry_workflow.rb +195 -0
  108. data/examples/workflow/simple_linear/README.adoc +267 -0
  109. data/examples/workflow/simple_linear/simple_linear_workflow.rb +175 -0
  110. data/examples/workflow/simplified/README.adoc +329 -0
  111. data/examples/workflow/simplified/simplified_workflow.rb +222 -0
  112. data/exe/fractor +10 -0
  113. data/lib/fractor/cli.rb +288 -0
  114. data/lib/fractor/configuration.rb +307 -0
  115. data/lib/fractor/continuous_server.rb +60 -65
  116. data/lib/fractor/error_formatter.rb +72 -0
  117. data/lib/fractor/error_report_generator.rb +152 -0
  118. data/lib/fractor/error_reporter.rb +244 -0
  119. data/lib/fractor/error_statistics.rb +147 -0
  120. data/lib/fractor/execution_tracer.rb +162 -0
  121. data/lib/fractor/logger.rb +230 -0
  122. data/lib/fractor/main_loop_handler.rb +406 -0
  123. data/lib/fractor/main_loop_handler3.rb +135 -0
  124. data/lib/fractor/main_loop_handler4.rb +299 -0
  125. data/lib/fractor/performance_metrics_collector.rb +181 -0
  126. data/lib/fractor/performance_monitor.rb +215 -0
  127. data/lib/fractor/performance_report_generator.rb +202 -0
  128. data/lib/fractor/priority_work.rb +93 -0
  129. data/lib/fractor/priority_work_queue.rb +189 -0
  130. data/lib/fractor/result_aggregator.rb +32 -0
  131. data/lib/fractor/shutdown_handler.rb +168 -0
  132. data/lib/fractor/signal_handler.rb +80 -0
  133. data/lib/fractor/supervisor.rb +382 -269
  134. data/lib/fractor/supervisor_logger.rb +88 -0
  135. data/lib/fractor/version.rb +1 -1
  136. data/lib/fractor/work.rb +12 -0
  137. data/lib/fractor/work_distribution_manager.rb +151 -0
  138. data/lib/fractor/work_queue.rb +20 -0
  139. data/lib/fractor/work_result.rb +181 -9
  140. data/lib/fractor/worker.rb +73 -0
  141. data/lib/fractor/workflow/builder.rb +210 -0
  142. data/lib/fractor/workflow/chain_builder.rb +169 -0
  143. data/lib/fractor/workflow/circuit_breaker.rb +183 -0
  144. data/lib/fractor/workflow/circuit_breaker_orchestrator.rb +208 -0
  145. data/lib/fractor/workflow/circuit_breaker_registry.rb +112 -0
  146. data/lib/fractor/workflow/dead_letter_queue.rb +334 -0
  147. data/lib/fractor/workflow/execution_hooks.rb +39 -0
  148. data/lib/fractor/workflow/execution_strategy.rb +225 -0
  149. data/lib/fractor/workflow/execution_trace.rb +134 -0
  150. data/lib/fractor/workflow/helpers.rb +191 -0
  151. data/lib/fractor/workflow/job.rb +290 -0
  152. data/lib/fractor/workflow/job_dependency_validator.rb +120 -0
  153. data/lib/fractor/workflow/logger.rb +110 -0
  154. data/lib/fractor/workflow/pre_execution_context.rb +193 -0
  155. data/lib/fractor/workflow/retry_config.rb +156 -0
  156. data/lib/fractor/workflow/retry_orchestrator.rb +184 -0
  157. data/lib/fractor/workflow/retry_strategy.rb +93 -0
  158. data/lib/fractor/workflow/structured_logger.rb +30 -0
  159. data/lib/fractor/workflow/type_compatibility_validator.rb +222 -0
  160. data/lib/fractor/workflow/visualizer.rb +211 -0
  161. data/lib/fractor/workflow/workflow_context.rb +132 -0
  162. data/lib/fractor/workflow/workflow_executor.rb +669 -0
  163. data/lib/fractor/workflow/workflow_result.rb +55 -0
  164. data/lib/fractor/workflow/workflow_validator.rb +295 -0
  165. data/lib/fractor/workflow.rb +333 -0
  166. data/lib/fractor/wrapped_ractor.rb +66 -101
  167. data/lib/fractor/wrapped_ractor3.rb +161 -0
  168. data/lib/fractor/wrapped_ractor4.rb +242 -0
  169. data/lib/fractor.rb +92 -4
  170. metadata +179 -6
  171. data/tests/sample.rb.bak +0 -309
  172. 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