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.
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,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