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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +227 -102
- data/README.adoc +113 -1940
- data/docs/.lycheeignore +16 -0
- data/docs/Gemfile +24 -0
- data/docs/README.md +157 -0
- data/docs/_config.yml +151 -0
- data/docs/_features/error-handling.adoc +1192 -0
- data/docs/_features/index.adoc +80 -0
- data/docs/_features/monitoring.adoc +589 -0
- data/docs/_features/signal-handling.adoc +202 -0
- data/docs/_features/workflows.adoc +1235 -0
- data/docs/_guides/continuous-mode.adoc +736 -0
- data/docs/_guides/cookbook.adoc +1133 -0
- data/docs/_guides/index.adoc +55 -0
- data/docs/_guides/pipeline-mode.adoc +730 -0
- data/docs/_guides/troubleshooting.adoc +358 -0
- data/docs/_pages/architecture.adoc +1390 -0
- data/docs/_pages/core-concepts.adoc +1392 -0
- data/docs/_pages/design-principles.adoc +862 -0
- data/docs/_pages/getting-started.adoc +290 -0
- data/docs/_pages/installation.adoc +143 -0
- data/docs/_reference/api.adoc +1080 -0
- data/docs/_reference/error-reporting.adoc +670 -0
- data/docs/_reference/examples.adoc +181 -0
- data/docs/_reference/index.adoc +96 -0
- data/docs/_reference/troubleshooting.adoc +862 -0
- data/docs/_tutorials/complex-workflows.adoc +1022 -0
- data/docs/_tutorials/data-processing-pipeline.adoc +740 -0
- data/docs/_tutorials/first-application.adoc +384 -0
- data/docs/_tutorials/index.adoc +48 -0
- data/docs/_tutorials/long-running-services.adoc +931 -0
- data/docs/assets/images/favicon-16.png +0 -0
- data/docs/assets/images/favicon-32.png +0 -0
- data/docs/assets/images/favicon-48.png +0 -0
- data/docs/assets/images/favicon.ico +0 -0
- data/docs/assets/images/favicon.png +0 -0
- data/docs/assets/images/favicon.svg +45 -0
- data/docs/assets/images/fractor-icon.svg +49 -0
- data/docs/assets/images/fractor-logo.svg +61 -0
- data/docs/index.adoc +131 -0
- data/docs/lychee.toml +39 -0
- data/examples/api_aggregator/README.adoc +627 -0
- data/examples/api_aggregator/api_aggregator.rb +376 -0
- data/examples/auto_detection/README.adoc +407 -29
- data/examples/continuous_chat_common/message_protocol.rb +1 -1
- data/examples/error_reporting.rb +207 -0
- data/examples/file_processor/README.adoc +170 -0
- data/examples/file_processor/file_processor.rb +615 -0
- data/examples/file_processor/sample_files/invalid.csv +1 -0
- data/examples/file_processor/sample_files/orders.xml +24 -0
- data/examples/file_processor/sample_files/products.json +23 -0
- data/examples/file_processor/sample_files/users.csv +6 -0
- data/examples/hierarchical_hasher/README.adoc +629 -41
- data/examples/image_processor/README.adoc +610 -0
- data/examples/image_processor/image_processor.rb +349 -0
- data/examples/image_processor/processed_images/sample_10_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_1_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_2_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_3_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_4_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_5_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_6_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_7_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_8_processed.jpg.json +12 -0
- data/examples/image_processor/processed_images/sample_9_processed.jpg.json +12 -0
- data/examples/image_processor/test_images/sample_1.png +1 -0
- data/examples/image_processor/test_images/sample_10.png +1 -0
- data/examples/image_processor/test_images/sample_2.png +1 -0
- data/examples/image_processor/test_images/sample_3.png +1 -0
- data/examples/image_processor/test_images/sample_4.png +1 -0
- data/examples/image_processor/test_images/sample_5.png +1 -0
- data/examples/image_processor/test_images/sample_6.png +1 -0
- data/examples/image_processor/test_images/sample_7.png +1 -0
- data/examples/image_processor/test_images/sample_8.png +1 -0
- data/examples/image_processor/test_images/sample_9.png +1 -0
- data/examples/log_analyzer/README.adoc +662 -0
- data/examples/log_analyzer/log_analyzer.rb +579 -0
- data/examples/log_analyzer/sample_logs/apache.log +20 -0
- data/examples/log_analyzer/sample_logs/json.log +15 -0
- data/examples/log_analyzer/sample_logs/nginx.log +15 -0
- data/examples/log_analyzer/sample_logs/rails.log +29 -0
- data/examples/multi_work_type/README.adoc +576 -26
- data/examples/performance_monitoring.rb +120 -0
- data/examples/pipeline_processing/README.adoc +740 -26
- data/examples/pipeline_processing/pipeline_processing.rb +2 -2
- data/examples/priority_work_example.rb +155 -0
- data/examples/producer_subscriber/README.adoc +889 -46
- data/examples/scatter_gather/README.adoc +829 -27
- data/examples/simple/README.adoc +347 -0
- data/examples/specialized_workers/README.adoc +622 -26
- data/examples/specialized_workers/specialized_workers.rb +44 -8
- data/examples/stream_processor/README.adoc +206 -0
- data/examples/stream_processor/stream_processor.rb +284 -0
- data/examples/web_scraper/README.adoc +625 -0
- data/examples/web_scraper/web_scraper.rb +285 -0
- data/examples/workflow/README.adoc +406 -0
- data/examples/workflow/circuit_breaker/README.adoc +360 -0
- data/examples/workflow/circuit_breaker/circuit_breaker_workflow.rb +225 -0
- data/examples/workflow/conditional/README.adoc +483 -0
- data/examples/workflow/conditional/conditional_workflow.rb +215 -0
- data/examples/workflow/dead_letter_queue/README.adoc +374 -0
- data/examples/workflow/dead_letter_queue/dead_letter_queue_workflow.rb +217 -0
- data/examples/workflow/fan_out/README.adoc +381 -0
- data/examples/workflow/fan_out/fan_out_workflow.rb +202 -0
- data/examples/workflow/retry/README.adoc +248 -0
- data/examples/workflow/retry/retry_workflow.rb +195 -0
- data/examples/workflow/simple_linear/README.adoc +267 -0
- data/examples/workflow/simple_linear/simple_linear_workflow.rb +175 -0
- data/examples/workflow/simplified/README.adoc +329 -0
- data/examples/workflow/simplified/simplified_workflow.rb +222 -0
- data/exe/fractor +10 -0
- data/lib/fractor/cli.rb +288 -0
- data/lib/fractor/configuration.rb +307 -0
- data/lib/fractor/continuous_server.rb +60 -65
- data/lib/fractor/error_formatter.rb +72 -0
- data/lib/fractor/error_report_generator.rb +152 -0
- data/lib/fractor/error_reporter.rb +244 -0
- data/lib/fractor/error_statistics.rb +147 -0
- data/lib/fractor/execution_tracer.rb +162 -0
- data/lib/fractor/logger.rb +230 -0
- data/lib/fractor/main_loop_handler.rb +406 -0
- data/lib/fractor/main_loop_handler3.rb +135 -0
- data/lib/fractor/main_loop_handler4.rb +299 -0
- data/lib/fractor/performance_metrics_collector.rb +181 -0
- data/lib/fractor/performance_monitor.rb +215 -0
- data/lib/fractor/performance_report_generator.rb +202 -0
- data/lib/fractor/priority_work.rb +93 -0
- data/lib/fractor/priority_work_queue.rb +189 -0
- data/lib/fractor/result_aggregator.rb +32 -0
- data/lib/fractor/shutdown_handler.rb +168 -0
- data/lib/fractor/signal_handler.rb +80 -0
- data/lib/fractor/supervisor.rb +382 -269
- data/lib/fractor/supervisor_logger.rb +88 -0
- data/lib/fractor/version.rb +1 -1
- data/lib/fractor/work.rb +12 -0
- data/lib/fractor/work_distribution_manager.rb +151 -0
- data/lib/fractor/work_queue.rb +20 -0
- data/lib/fractor/work_result.rb +181 -9
- data/lib/fractor/worker.rb +73 -0
- data/lib/fractor/workflow/builder.rb +210 -0
- data/lib/fractor/workflow/chain_builder.rb +169 -0
- data/lib/fractor/workflow/circuit_breaker.rb +183 -0
- data/lib/fractor/workflow/circuit_breaker_orchestrator.rb +208 -0
- data/lib/fractor/workflow/circuit_breaker_registry.rb +112 -0
- data/lib/fractor/workflow/dead_letter_queue.rb +334 -0
- data/lib/fractor/workflow/execution_hooks.rb +39 -0
- data/lib/fractor/workflow/execution_strategy.rb +225 -0
- data/lib/fractor/workflow/execution_trace.rb +134 -0
- data/lib/fractor/workflow/helpers.rb +191 -0
- data/lib/fractor/workflow/job.rb +290 -0
- data/lib/fractor/workflow/job_dependency_validator.rb +120 -0
- data/lib/fractor/workflow/logger.rb +110 -0
- data/lib/fractor/workflow/pre_execution_context.rb +193 -0
- data/lib/fractor/workflow/retry_config.rb +156 -0
- data/lib/fractor/workflow/retry_orchestrator.rb +184 -0
- data/lib/fractor/workflow/retry_strategy.rb +93 -0
- data/lib/fractor/workflow/structured_logger.rb +30 -0
- data/lib/fractor/workflow/type_compatibility_validator.rb +222 -0
- data/lib/fractor/workflow/visualizer.rb +211 -0
- data/lib/fractor/workflow/workflow_context.rb +132 -0
- data/lib/fractor/workflow/workflow_executor.rb +669 -0
- data/lib/fractor/workflow/workflow_result.rb +55 -0
- data/lib/fractor/workflow/workflow_validator.rb +295 -0
- data/lib/fractor/workflow.rb +333 -0
- data/lib/fractor/wrapped_ractor.rb +66 -101
- data/lib/fractor/wrapped_ractor3.rb +161 -0
- data/lib/fractor/wrapped_ractor4.rb +242 -0
- data/lib/fractor.rb +92 -4
- metadata +179 -6
- data/tests/sample.rb.bak +0 -309
- 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
|