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,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "circuit_breaker"
4
+
5
+ module Fractor
6
+ class Workflow
7
+ # Orchestrates circuit breaker logic for workflow job execution.
8
+ # Wraps a CircuitBreaker and provides workflow-specific integration.
9
+ #
10
+ # @example Basic usage
11
+ # orchestrator = CircuitBreakerOrchestrator.new(threshold: 5, timeout: 60)
12
+ # result = orchestrator.execute_with_breaker(job) { |job| execute_job(job) }
13
+ class CircuitBreakerOrchestrator
14
+ attr_reader :breaker, :debug, :job_name
15
+
16
+ # Initialize a new circuit breaker orchestrator.
17
+ #
18
+ # @param threshold [Integer] Number of failures before opening circuit
19
+ # @param timeout [Integer] Seconds to wait before trying half-open
20
+ # @param half_open_calls [Integer] Number of test calls in half-open
21
+ # @param job_name [String] Optional job name for logging
22
+ # @param debug [Boolean] Whether to enable debug logging
23
+ def initialize(threshold: 5, timeout: 60, half_open_calls: 3,
24
+ job_name: nil, debug: false)
25
+ @breaker = CircuitBreaker.new(
26
+ threshold: threshold,
27
+ timeout: timeout,
28
+ half_open_calls: half_open_calls,
29
+ )
30
+ @job_name = job_name
31
+ @debug = debug
32
+ @execution_count = 0
33
+ @success_count = 0
34
+ @blocked_count = 0
35
+ end
36
+
37
+ # Execute a job with circuit breaker protection.
38
+ #
39
+ # @param job [Job] The job to execute
40
+ # @yield [Job] Block that executes the job
41
+ # @return [Object] The execution result
42
+ # @raise [CircuitOpenError] If circuit is open
43
+ def execute_with_breaker(job, &)
44
+ @execution_count += 1
45
+
46
+ log_debug "Executing job '#{job.name}' with circuit breaker protection"
47
+
48
+ check_and_call_breaker(job, &)
49
+ rescue CircuitOpenError => e
50
+ @blocked_count += 1
51
+ log_debug "Job '#{job.name}' blocked by circuit breaker: #{e.message}"
52
+ raise
53
+ rescue StandardError => e
54
+ log_debug "Job '#{job.name}' failed with #{e.class}"
55
+ raise
56
+ end
57
+
58
+ # Check if the circuit is currently open.
59
+ #
60
+ # @return [Boolean] true if circuit is open
61
+ def open?
62
+ @breaker.open?
63
+ end
64
+
65
+ # Check if the circuit is currently closed.
66
+ #
67
+ # @return [Boolean] true if circuit is closed
68
+ def closed?
69
+ @breaker.closed?
70
+ end
71
+
72
+ # Check if the circuit is currently half-open.
73
+ #
74
+ # @return [Boolean] true if circuit is half-open
75
+ def half_open?
76
+ @breaker.half_open?
77
+ end
78
+
79
+ # Get the current circuit breaker state.
80
+ #
81
+ # @return [Symbol] The state (:closed, :open, :half_open)
82
+ def state
83
+ @breaker.state
84
+ end
85
+
86
+ # Get the failure count.
87
+ #
88
+ # @return [Integer] Number of failures recorded
89
+ def failure_count
90
+ @breaker.failure_count
91
+ end
92
+
93
+ # Get the last failure time.
94
+ #
95
+ # @return [Time, nil] Last failure time or nil
96
+ def last_failure_time
97
+ @breaker.last_failure_time
98
+ end
99
+
100
+ # Reset the circuit breaker to closed state.
101
+ def reset!
102
+ @breaker.reset
103
+ @execution_count = 0
104
+ @success_count = 0
105
+ @blocked_count = 0
106
+ log_debug "Circuit breaker reset for job '#{@job_name}'"
107
+ end
108
+
109
+ # Get circuit breaker statistics including orchestrator metrics.
110
+ #
111
+ # @return [Hash] Statistics and metrics
112
+ def stats
113
+ @breaker.stats.merge(
114
+ execution_count: @execution_count,
115
+ success_count: @success_count,
116
+ blocked_count: @blocked_count,
117
+ )
118
+ end
119
+
120
+ # Get the current state as a human-readable string.
121
+ #
122
+ # @return [String] State description
123
+ def state_description
124
+ case state
125
+ when CircuitBreaker::STATE_CLOSED
126
+ "CLOSED (normal operation)"
127
+ when CircuitBreaker::STATE_OPEN
128
+ "OPEN (blocking requests, #{failure_count}/#{@breaker.threshold} failures)"
129
+ when CircuitBreaker::STATE_HALF_OPEN
130
+ "HALF_OPEN (testing recovery, #{@breaker.instance_variable_get(:@success_count)}/#{@breaker.half_open_calls} successes)"
131
+ else
132
+ "UNKNOWN"
133
+ end
134
+ end
135
+
136
+ # Try to execute the job regardless of circuit state.
137
+ # This bypasses the circuit breaker but still tracks results.
138
+ #
139
+ # @param job [Job] The job to execute
140
+ # @yield [Job] Block that executes the job
141
+ # @return [Object] The execution result
142
+ def execute_bypassing_breaker(job)
143
+ @execution_count += 1
144
+
145
+ log_debug "Executing job '#{job.name}' bypassing circuit breaker"
146
+
147
+ result = yield(job)
148
+ @success_count += 1
149
+ result
150
+ rescue StandardError => e
151
+ log_debug "Bypassed execution failed: #{e.class}"
152
+ raise
153
+ end
154
+
155
+ # Manually open the circuit (for testing or emergency).
156
+ def open_circuit!
157
+ @breaker.instance_variable_get(:@mutex).synchronize do
158
+ @breaker.instance_variable_set(:@state, CircuitBreaker::STATE_OPEN)
159
+ @breaker.instance_variable_set(:@failure_count, @breaker.threshold)
160
+ @breaker.instance_variable_set(:@last_failure_time, Time.now)
161
+ end
162
+ log_debug "Circuit manually opened for job '#{@job_name}'"
163
+ end
164
+
165
+ # Manually close the circuit (for testing or recovery).
166
+ def close_circuit!
167
+ @breaker.reset
168
+ log_debug "Circuit manually closed for job '#{@job_name}'"
169
+ end
170
+
171
+ private
172
+
173
+ # Check circuit state and call the breaker.
174
+ #
175
+ # @param job [Job] The job to execute
176
+ # @yield [Job] Block that executes the job
177
+ # @return [Object] The execution result
178
+ def check_and_call_breaker(job, &)
179
+ result = @breaker.call(&)
180
+
181
+ @success_count += 1
182
+ log_success(job) if @debug
183
+
184
+ result
185
+ end
186
+
187
+ # Log a successful execution.
188
+ #
189
+ # @param job [Job] The job that succeeded
190
+ def log_success(job)
191
+ state_info = case state
192
+ when CircuitBreaker::STATE_CLOSED then "(closed)"
193
+ when CircuitBreaker::STATE_HALF_OPEN then "(half-open, recovering)"
194
+ else ""
195
+ end
196
+
197
+ puts "[CircuitBreakerOrchestrator] Job '#{job.name}' succeeded #{state_info}" if @debug
198
+ end
199
+
200
+ # Log a debug message.
201
+ #
202
+ # @param message [String] The message to log
203
+ def log_debug(message)
204
+ puts "[CircuitBreakerOrchestrator] #{message}" if @debug
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "circuit_breaker_orchestrator"
4
+
5
+ module Fractor
6
+ class Workflow
7
+ # Registry for managing circuit breakers across jobs
8
+ #
9
+ # Provides centralized circuit breaker management, allowing multiple
10
+ # jobs to share circuit breakers or have isolated ones.
11
+ #
12
+ # @example Shared circuit breaker
13
+ # registry = CircuitBreakerRegistry.new
14
+ # breaker = registry.get_or_create("api", threshold: 5)
15
+ #
16
+ # @example Per-job circuit breaker
17
+ # registry = CircuitBreakerRegistry.new
18
+ # breaker = registry.get_or_create("job_123", threshold: 3)
19
+ #
20
+ # @example Circuit breaker orchestrator
21
+ # registry = CircuitBreakerRegistry.new
22
+ # orchestrator = registry.get_or_create_orchestrator("api", threshold: 5, job_name: "my_job")
23
+ class CircuitBreakerRegistry
24
+ def initialize
25
+ @breakers = {}
26
+ @orchestrators = {}
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ # Get or create a circuit breaker
31
+ #
32
+ # @param key [String] Unique identifier for the circuit breaker
33
+ # @param options [Hash] Circuit breaker options
34
+ # @option options [Integer] :threshold Failure threshold
35
+ # @option options [Integer] :timeout Timeout in seconds
36
+ # @option options [Integer] :half_open_calls Test calls in half-open
37
+ # @return [CircuitBreaker] The circuit breaker
38
+ def get_or_create(key, **options)
39
+ @mutex.synchronize do
40
+ @breakers[key] ||= CircuitBreaker.new(**options)
41
+ end
42
+ end
43
+
44
+ # Get or create a circuit breaker orchestrator
45
+ #
46
+ # @param key [String] Unique identifier for the circuit breaker
47
+ # @param options [Hash] Circuit breaker orchestrator options
48
+ # @option options [Integer] :threshold Failure threshold
49
+ # @option options [Integer] :timeout Timeout in seconds
50
+ # @option options [Integer] :half_open_calls Test calls in half-open
51
+ # @option options [String] :job_name Job name for logging
52
+ # @option options [Boolean] :debug Debug logging flag
53
+ # @return [CircuitBreakerOrchestrator] The circuit breaker orchestrator
54
+ def get_or_create_orchestrator(key, **options)
55
+ @mutex.synchronize do
56
+ @orchestrators[key] ||= CircuitBreakerOrchestrator.new(**options)
57
+ end
58
+ end
59
+
60
+ # Get an existing circuit breaker
61
+ #
62
+ # @param key [String] Unique identifier for the circuit breaker
63
+ # @return [CircuitBreaker, nil] The circuit breaker or nil
64
+ def get(key)
65
+ @breakers[key]
66
+ end
67
+
68
+ # Get an existing circuit breaker orchestrator
69
+ #
70
+ # @param key [String] Unique identifier for the orchestrator
71
+ # @return [CircuitBreakerOrchestrator, nil] The orchestrator or nil
72
+ def get_orchestrator(key)
73
+ @orchestrators[key]
74
+ end
75
+
76
+ # Remove a circuit breaker
77
+ #
78
+ # @param key [String] Unique identifier for the circuit breaker
79
+ # @return [CircuitBreaker, CircuitBreakerOrchestrator, nil] The removed object or nil
80
+ def remove(key)
81
+ @mutex.synchronize do
82
+ @breakers.delete(key) || @orchestrators.delete(key)
83
+ end
84
+ end
85
+
86
+ # Reset all circuit breakers and orchestrators
87
+ def reset_all
88
+ @mutex.synchronize do
89
+ @breakers.each_value(&:reset)
90
+ @orchestrators.each_value(&:reset!)
91
+ end
92
+ end
93
+
94
+ # Get statistics for all circuit breakers and orchestrators
95
+ #
96
+ # @return [Hash] Map of key to circuit breaker/orchestrator statistics
97
+ def all_stats
98
+ breakers_stats = @breakers.transform_values(&:stats)
99
+ orchestrators_stats = @orchestrators.transform_values(&:stats)
100
+ breakers_stats.merge(orchestrators_stats)
101
+ end
102
+
103
+ # Clear all circuit breakers and orchestrators
104
+ def clear
105
+ @mutex.synchronize do
106
+ @breakers.clear
107
+ @orchestrators.clear
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractor
4
+ class Workflow
5
+ # Dead Letter Queue for capturing permanently failed work
6
+ #
7
+ # The dead letter queue captures work items that have exhausted all
8
+ # retry attempts and cannot be processed successfully. This provides
9
+ # a mechanism for:
10
+ #
11
+ # - Preventing data loss for failed items
12
+ # - Enabling manual inspection and reprocessing
13
+ # - Supporting different persistence strategies
14
+ # - Providing visibility into failure patterns
15
+ #
16
+ # @example Basic usage
17
+ # dlq = DeadLetterQueue.new(max_size: 1000)
18
+ # dlq.add(work, error, context)
19
+ # failed_items = dlq.all
20
+ #
21
+ # @example With handler
22
+ # dlq = DeadLetterQueue.new
23
+ # dlq.on_add do |entry|
24
+ # Logger.error("Dead letter: #{entry.error}")
25
+ # end
26
+ class DeadLetterQueue
27
+ # Entry in the dead letter queue
28
+ class Entry
29
+ attr_reader :work, :error, :context, :timestamp, :metadata
30
+
31
+ def initialize(work:, error:, context: nil, metadata: {})
32
+ @work = work
33
+ @error = error
34
+ @context = context
35
+ @timestamp = Time.now
36
+ @metadata = metadata
37
+ end
38
+
39
+ # Convert entry to hash for serialization
40
+ #
41
+ # @return [Hash] Entry data as hash
42
+ def to_h
43
+ {
44
+ work: work,
45
+ error: error.to_s,
46
+ error_class: error.class.name,
47
+ context: context&.to_h,
48
+ timestamp: timestamp.iso8601,
49
+ metadata: metadata,
50
+ }
51
+ end
52
+ end
53
+
54
+ attr_reader :max_size, :entries
55
+
56
+ # Initialize a new dead letter queue
57
+ #
58
+ # @param max_size [Integer] Maximum queue size (nil for unlimited)
59
+ # @param persistence [Symbol] Persistence strategy (:memory, :file, :redis, :database)
60
+ # @param persistence_options [Hash] Options for persistence backend
61
+ def initialize(max_size: nil, persistence: :memory, **persistence_options)
62
+ @max_size = max_size
63
+ @entries = []
64
+ @handlers = []
65
+ @mutex = Mutex.new
66
+ @persistence = persistence
67
+ @persistence_options = persistence_options
68
+ @persister = create_persister(persistence, persistence_options)
69
+ end
70
+
71
+ # Add a failed work item to the dead letter queue
72
+ #
73
+ # @param work [Fractor::Work] The failed work item
74
+ # @param error [Exception] The error that caused failure
75
+ # @param context [Hash] Additional context about the failure
76
+ # @param metadata [Hash] Additional metadata
77
+ # @return [Entry] The created entry
78
+ def add(work, error, context: nil, metadata: {})
79
+ entry = Entry.new(
80
+ work: work,
81
+ error: error,
82
+ context: context,
83
+ metadata: metadata,
84
+ )
85
+
86
+ @mutex.synchronize do
87
+ # Enforce max size if set
88
+ if max_size && @entries.size >= max_size
89
+ # Remove oldest entry
90
+ removed = @entries.shift
91
+ @persister&.remove(removed)
92
+ end
93
+
94
+ @entries << entry
95
+ @persister&.persist(entry)
96
+ end
97
+
98
+ # Notify handlers
99
+ notify_handlers(entry)
100
+
101
+ entry
102
+ end
103
+
104
+ # Enqueue a failed work item to the dead letter queue (alias for add)
105
+ # Standardized API method name for consistency across queue implementations
106
+ #
107
+ # @param work [Fractor::Work] The failed work item
108
+ # @param error [Exception] The error that caused failure
109
+ # @param context [Hash] Additional context about the failure
110
+ # @param metadata [Hash] Additional metadata
111
+ # @return [Entry] The created entry
112
+ def enqueue(work, error, context: nil, metadata: {})
113
+ add(work, error, context: context, metadata: metadata)
114
+ end
115
+
116
+ # Register a handler to be called when items are added
117
+ #
118
+ # @yield [Entry] Block to execute when item is added
119
+ def on_add(&block)
120
+ @handlers << block if block
121
+ end
122
+
123
+ # Get all entries in the queue
124
+ #
125
+ # @return [Array<Entry>] All entries
126
+ def all
127
+ @mutex.synchronize { @entries.dup }
128
+ end
129
+
130
+ # Get entries matching a filter
131
+ #
132
+ # @yield [Entry] Block to filter entries
133
+ # @return [Array<Entry>] Filtered entries
134
+ def filter(&block)
135
+ @mutex.synchronize { @entries.select(&block) }
136
+ end
137
+
138
+ # Get entries for a specific error class
139
+ #
140
+ # @param error_class [Class] Error class to filter by
141
+ # @return [Array<Entry>] Matching entries
142
+ def by_error_class(error_class)
143
+ filter { |entry| entry.error.is_a?(error_class) }
144
+ end
145
+
146
+ # Get entries within a time range
147
+ #
148
+ # @param start_time [Time] Start of time range
149
+ # @param end_time [Time] End of time range (defaults to now)
150
+ # @return [Array<Entry>] Entries in time range
151
+ def by_time_range(start_time, end_time = Time.now)
152
+ filter do |entry|
153
+ entry.timestamp >= start_time && entry.timestamp <= end_time
154
+ end
155
+ end
156
+
157
+ # Remove an entry from the queue
158
+ #
159
+ # @param entry [Entry] Entry to remove
160
+ # @return [Entry, nil] Removed entry or nil
161
+ def remove(entry)
162
+ @mutex.synchronize do
163
+ if @entries.delete(entry)
164
+ @persister&.remove(entry)
165
+ entry
166
+ end
167
+ end
168
+ end
169
+
170
+ # Clear all entries from the queue
171
+ #
172
+ # @return [Integer] Number of entries cleared
173
+ def clear
174
+ @mutex.synchronize do
175
+ count = @entries.size
176
+ @entries.clear
177
+ @persister&.clear
178
+ count
179
+ end
180
+ end
181
+
182
+ # Get the current size of the queue
183
+ #
184
+ # @return [Integer] Number of entries
185
+ def size
186
+ @mutex.synchronize { @entries.size }
187
+ end
188
+
189
+ # Check if queue is empty
190
+ #
191
+ # @return [Boolean] True if empty
192
+ def empty?
193
+ size.zero?
194
+ end
195
+
196
+ # Check if queue is at capacity
197
+ #
198
+ # @return [Boolean] True if at max_size
199
+ def full?
200
+ return false unless max_size
201
+
202
+ size >= max_size
203
+ end
204
+
205
+ # Get statistics about the queue
206
+ #
207
+ # @return [Hash] Queue statistics
208
+ def stats
209
+ entries_copy = all
210
+
211
+ {
212
+ size: entries_copy.size,
213
+ max_size: max_size,
214
+ full: full?,
215
+ oldest_timestamp: entries_copy.first&.timestamp,
216
+ newest_timestamp: entries_copy.last&.timestamp,
217
+ error_classes: entries_copy.map { |e| e.error.class.name }.uniq,
218
+ persistence: @persistence,
219
+ }
220
+ end
221
+
222
+ # Retry a specific entry
223
+ #
224
+ # @param entry [Entry] Entry to retry
225
+ # @yield [Work] Block to process the work
226
+ # @return [Boolean] True if retry succeeded
227
+ def retry_entry(entry, &block)
228
+ return false unless block
229
+
230
+ begin
231
+ yield(entry.work)
232
+ remove(entry)
233
+ true
234
+ rescue StandardError => e
235
+ # Add back to queue with new error
236
+ remove(entry)
237
+ add(entry.work, e, context: entry.context,
238
+ metadata: entry.metadata.merge(retried: true))
239
+ false
240
+ end
241
+ end
242
+
243
+ # Retry all entries
244
+ #
245
+ # @yield [Work] Block to process each work item
246
+ # @return [Hash] Results with :success and :failed counts
247
+ def retry_all(&block)
248
+ return { success: 0, failed: 0 } unless block
249
+
250
+ results = { success: 0, failed: 0 }
251
+ entries_to_retry = all
252
+
253
+ entries_to_retry.each do |entry|
254
+ if retry_entry(entry, &block)
255
+ results[:success] += 1
256
+ else
257
+ results[:failed] += 1
258
+ end
259
+ end
260
+
261
+ results
262
+ end
263
+
264
+ private
265
+
266
+ def notify_handlers(entry)
267
+ @handlers.each do |handler|
268
+ handler.call(entry)
269
+ rescue StandardError => e
270
+ warn "Dead letter queue handler error: #{e.message}"
271
+ end
272
+ end
273
+
274
+ def create_persister(persistence, options)
275
+ case persistence
276
+ when :memory
277
+ nil # No persistence needed
278
+ when :file
279
+ FilePersister.new(**options)
280
+ when :redis
281
+ RedisPersister.new(**options) if defined?(Redis)
282
+ when :database
283
+ DatabasePersister.new(**options)
284
+ else
285
+ raise ArgumentError, "Unknown persistence strategy: #{persistence}"
286
+ end
287
+ end
288
+ end
289
+
290
+ # File-based persistence for dead letter queue
291
+ class FilePersister
292
+ def initialize(file_path: "dead_letter_queue.json")
293
+ @file_path = file_path
294
+ @mutex = Mutex.new
295
+ end
296
+
297
+ def persist(entry)
298
+ @mutex.synchronize do
299
+ entries = load_entries
300
+ entries << entry.to_h
301
+ save_entries(entries)
302
+ end
303
+ end
304
+
305
+ def remove(entry)
306
+ @mutex.synchronize do
307
+ entries = load_entries
308
+ entries.reject! { |e| e[:timestamp] == entry.timestamp.iso8601 }
309
+ save_entries(entries)
310
+ end
311
+ end
312
+
313
+ def clear
314
+ @mutex.synchronize do
315
+ File.delete(@file_path) if File.exist?(@file_path)
316
+ end
317
+ end
318
+
319
+ private
320
+
321
+ def load_entries
322
+ return [] unless File.exist?(@file_path)
323
+
324
+ JSON.parse(File.read(@file_path), symbolize_names: true)
325
+ rescue JSON::ParserError
326
+ []
327
+ end
328
+
329
+ def save_entries(entries)
330
+ File.write(@file_path, JSON.pretty_generate(entries))
331
+ end
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractor
4
+ class Workflow
5
+ # Manages lifecycle hooks for workflow execution.
6
+ # Allows registering callbacks for workflow/job lifecycle events.
7
+ class ExecutionHooks
8
+ def initialize
9
+ @hooks = Hash.new { |h, k| h[k] = [] }
10
+ end
11
+
12
+ # Register a callback for a specific event.
13
+ #
14
+ # @param event [Symbol] The event to hook into
15
+ # @yield [Object] Block to execute when event is triggered
16
+ #
17
+ # @example Register a workflow start hook
18
+ # hooks.register(:workflow_start) do |workflow|
19
+ # puts "Workflow starting: #{workflow.class.workflow_name}"
20
+ # end
21
+ def register(event, &block)
22
+ @hooks[event] << block
23
+ end
24
+
25
+ # Trigger all callbacks registered for an event.
26
+ #
27
+ # @param event [Symbol] The event to trigger
28
+ # @param args [Array] Arguments to pass to the callbacks
29
+ #
30
+ # @example Trigger workflow completion
31
+ # hooks.trigger(:workflow_complete, result)
32
+ def trigger(event, *args)
33
+ @hooks[event].each do |hook|
34
+ hook.call(*args)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end