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