fractor 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Fractor
|
|
6
|
+
# Generates formatted reports from performance metrics snapshots.
|
|
7
|
+
# Supports text, JSON, and Prometheus output formats.
|
|
8
|
+
class PerformanceReportGenerator
|
|
9
|
+
# Generate a human-readable text report
|
|
10
|
+
#
|
|
11
|
+
# @param stats [Hash] Metrics snapshot from PerformanceMonitor
|
|
12
|
+
# @return [String] Formatted report
|
|
13
|
+
def self.generate_report(stats)
|
|
14
|
+
<<~REPORT
|
|
15
|
+
Performance Metrics
|
|
16
|
+
===================
|
|
17
|
+
Duration: #{format_duration(stats[:uptime])}
|
|
18
|
+
|
|
19
|
+
Jobs:
|
|
20
|
+
Processed: #{stats[:jobs_processed]}
|
|
21
|
+
Succeeded: #{stats[:jobs_succeeded]}
|
|
22
|
+
Failed: #{stats[:jobs_failed]}
|
|
23
|
+
Success Rate: #{success_rate(stats)}%
|
|
24
|
+
|
|
25
|
+
Latency (ms):
|
|
26
|
+
Average: #{format_ms(stats[:average_latency])}
|
|
27
|
+
P50: #{format_ms(stats[:p50_latency])}
|
|
28
|
+
P95: #{format_ms(stats[:p95_latency])}
|
|
29
|
+
P99: #{format_ms(stats[:p99_latency])}
|
|
30
|
+
|
|
31
|
+
Throughput:
|
|
32
|
+
Jobs/sec: #{format_float(stats[:throughput])}
|
|
33
|
+
|
|
34
|
+
Workers:
|
|
35
|
+
Total: #{stats[:worker_count]}
|
|
36
|
+
Active: #{stats[:active_workers]}
|
|
37
|
+
Utilization: #{format_percent(stats[:worker_utilization])}
|
|
38
|
+
|
|
39
|
+
Queue:
|
|
40
|
+
Current Depth: #{stats[:queue_depth]}
|
|
41
|
+
Average Depth: #{format_float(stats[:queue_depth_avg])}
|
|
42
|
+
Max Depth: #{stats[:queue_depth_max]}
|
|
43
|
+
Enqueue Rate: #{format_float(stats[:enqueue_rate])} items/sec
|
|
44
|
+
Dequeue Rate: #{format_float(stats[:dequeue_rate])} items/sec
|
|
45
|
+
|
|
46
|
+
Wait Time (ms):
|
|
47
|
+
Average: #{format_ms(stats[:average_wait_time])}
|
|
48
|
+
P50: #{format_ms(stats[:p50_wait_time])}
|
|
49
|
+
P95: #{format_ms(stats[:p95_wait_time])}
|
|
50
|
+
P99: #{format_ms(stats[:p99_wait_time])}
|
|
51
|
+
|
|
52
|
+
Memory:
|
|
53
|
+
Current: #{format_float(stats[:memory_mb])} MB
|
|
54
|
+
REPORT
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Export metrics in JSON format
|
|
58
|
+
#
|
|
59
|
+
# @param stats [Hash] Metrics snapshot
|
|
60
|
+
# @return [String] JSON representation
|
|
61
|
+
def self.to_json(stats)
|
|
62
|
+
stats.to_json
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Export metrics in Prometheus format
|
|
66
|
+
#
|
|
67
|
+
# @param stats [Hash] Metrics snapshot
|
|
68
|
+
# @param total_latency [Float] Total latency for all jobs
|
|
69
|
+
# @param prefix [String] Metric name prefix
|
|
70
|
+
# @return [String] Prometheus metrics
|
|
71
|
+
def self.to_prometheus(stats, total_latency, prefix: "fractor")
|
|
72
|
+
<<~PROMETHEUS
|
|
73
|
+
# HELP #{prefix}_jobs_processed_total Total number of jobs processed
|
|
74
|
+
# TYPE #{prefix}_jobs_processed_total counter
|
|
75
|
+
#{prefix}_jobs_processed_total #{stats[:jobs_processed]}
|
|
76
|
+
|
|
77
|
+
# HELP #{prefix}_jobs_succeeded_total Total number of jobs that succeeded
|
|
78
|
+
# TYPE #{prefix}_jobs_succeeded_total counter
|
|
79
|
+
#{prefix}_jobs_succeeded_total #{stats[:jobs_succeeded]}
|
|
80
|
+
|
|
81
|
+
# HELP #{prefix}_jobs_failed_total Total number of jobs that failed
|
|
82
|
+
# TYPE #{prefix}_jobs_failed_total counter
|
|
83
|
+
#{prefix}_jobs_failed_total #{stats[:jobs_failed]}
|
|
84
|
+
|
|
85
|
+
# HELP #{prefix}_latency_seconds Job processing latency
|
|
86
|
+
# TYPE #{prefix}_latency_seconds summary
|
|
87
|
+
#{prefix}_latency_seconds{quantile="0.5"} #{stats[:p50_latency] || 0}
|
|
88
|
+
#{prefix}_latency_seconds{quantile="0.95"} #{stats[:p95_latency] || 0}
|
|
89
|
+
#{prefix}_latency_seconds{quantile="0.99"} #{stats[:p99_latency] || 0}
|
|
90
|
+
#{prefix}_latency_seconds_sum #{total_latency}
|
|
91
|
+
#{prefix}_latency_seconds_count #{stats[:jobs_processed]}
|
|
92
|
+
|
|
93
|
+
# HELP #{prefix}_throughput_jobs_per_second Current throughput
|
|
94
|
+
# TYPE #{prefix}_throughput_jobs_per_second gauge
|
|
95
|
+
#{prefix}_throughput_jobs_per_second #{stats[:throughput] || 0}
|
|
96
|
+
|
|
97
|
+
# HELP #{prefix}_queue_depth Current queue depth
|
|
98
|
+
# TYPE #{prefix}_queue_depth gauge
|
|
99
|
+
#{prefix}_queue_depth #{stats[:queue_depth]}
|
|
100
|
+
|
|
101
|
+
# HELP #{prefix}_queue_depth_avg Average queue depth
|
|
102
|
+
# TYPE #{prefix}_queue_depth_avg gauge
|
|
103
|
+
#{prefix}_queue_depth_avg #{stats[:queue_depth_avg] || 0}
|
|
104
|
+
|
|
105
|
+
# HELP #{prefix}_queue_depth_max Maximum queue depth
|
|
106
|
+
# TYPE #{prefix}_queue_depth_max gauge
|
|
107
|
+
#{prefix}_queue_depth_max #{stats[:queue_depth_max] || 0}
|
|
108
|
+
|
|
109
|
+
# HELP #{prefix}_enqueue_rate_total Items enqueued per second
|
|
110
|
+
# TYPE #{prefix}_enqueue_rate_total gauge
|
|
111
|
+
#{prefix}_enqueue_rate_total #{stats[:enqueue_rate] || 0}
|
|
112
|
+
|
|
113
|
+
# HELP #{prefix}_dequeue_rate_total Items dequeued per second
|
|
114
|
+
# TYPE #{prefix}_dequeue_rate_total gauge
|
|
115
|
+
#{prefix}_dequeue_rate_total #{stats[:dequeue_rate] || 0}
|
|
116
|
+
|
|
117
|
+
# HELP #{prefix}_wait_time_seconds Queue wait time
|
|
118
|
+
# TYPE #{prefix}_wait_time_seconds summary
|
|
119
|
+
#{prefix}_wait_time_seconds{quantile="0.5"} #{stats[:p50_wait_time] || 0}
|
|
120
|
+
#{prefix}_wait_time_seconds{quantile="0.95"} #{stats[:p95_wait_time] || 0}
|
|
121
|
+
#{prefix}_wait_time_seconds{quantile="0.99"} #{stats[:p99_wait_time] || 0}
|
|
122
|
+
#{prefix}_wait_time_seconds_sum #{stats[:average_wait_time] || 0}
|
|
123
|
+
#{prefix}_wait_time_seconds_count #{stats[:jobs_processed]}
|
|
124
|
+
|
|
125
|
+
# HELP #{prefix}_workers_total Total number of workers
|
|
126
|
+
# TYPE #{prefix}_workers_total gauge
|
|
127
|
+
#{prefix}_workers_total #{stats[:worker_count]}
|
|
128
|
+
|
|
129
|
+
# HELP #{prefix}_workers_active Number of active workers
|
|
130
|
+
# TYPE #{prefix}_workers_active gauge
|
|
131
|
+
#{prefix}_workers_active #{stats[:active_workers]}
|
|
132
|
+
|
|
133
|
+
# HELP #{prefix}_worker_utilization Worker utilization ratio
|
|
134
|
+
# TYPE #{prefix}_worker_utilization gauge
|
|
135
|
+
#{prefix}_worker_utilization #{stats[:worker_utilization] || 0}
|
|
136
|
+
|
|
137
|
+
# HELP #{prefix}_memory_bytes Current memory usage
|
|
138
|
+
# TYPE #{prefix}_memory_bytes gauge
|
|
139
|
+
#{prefix}_memory_bytes #{(stats[:memory_mb] || 0) * 1024 * 1024}
|
|
140
|
+
PROMETHEUS
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Calculate success rate from metrics
|
|
144
|
+
#
|
|
145
|
+
# @param stats [Hash] Metrics snapshot
|
|
146
|
+
# @return [Float] Success rate percentage
|
|
147
|
+
def self.success_rate(stats)
|
|
148
|
+
total = stats[:jobs_processed]
|
|
149
|
+
return 0.0 if total.zero?
|
|
150
|
+
|
|
151
|
+
(stats[:jobs_succeeded].to_f / total * 100).round(2)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Format duration in human-readable format
|
|
155
|
+
#
|
|
156
|
+
# @param seconds [Float] Duration in seconds
|
|
157
|
+
# @return [String] Formatted duration (e.g., "1h 30m 45s")
|
|
158
|
+
def self.format_duration(seconds)
|
|
159
|
+
return "0s" if seconds.nil? || seconds.zero?
|
|
160
|
+
|
|
161
|
+
hours = (seconds / 3600).floor
|
|
162
|
+
minutes = ((seconds % 3600) / 60).floor
|
|
163
|
+
secs = (seconds % 60).round(2)
|
|
164
|
+
|
|
165
|
+
parts = []
|
|
166
|
+
parts << "#{hours}h" if hours.positive?
|
|
167
|
+
parts << "#{minutes}m" if minutes.positive?
|
|
168
|
+
parts << "#{secs}s"
|
|
169
|
+
parts.join(" ")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Format seconds as milliseconds
|
|
173
|
+
#
|
|
174
|
+
# @param seconds [Float] Duration in seconds
|
|
175
|
+
# @return [String] Milliseconds with 2 decimal places
|
|
176
|
+
def self.format_ms(seconds)
|
|
177
|
+
return "0.00" if seconds.nil?
|
|
178
|
+
|
|
179
|
+
(seconds * 1000).round(2)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Format float value with 2 decimal places
|
|
183
|
+
#
|
|
184
|
+
# @param value [Float] Value to format
|
|
185
|
+
# @return [String] Formatted float
|
|
186
|
+
def self.format_float(value)
|
|
187
|
+
return "0.00" if value.nil?
|
|
188
|
+
|
|
189
|
+
value.round(2)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Format ratio as percentage
|
|
193
|
+
#
|
|
194
|
+
# @param ratio [Float] Ratio (0.0 to 1.0)
|
|
195
|
+
# @return [String] Percentage with 2 decimal places
|
|
196
|
+
def self.format_percent(ratio)
|
|
197
|
+
return "0.00%" if ratio.nil?
|
|
198
|
+
|
|
199
|
+
"#{(ratio * 100).round(2)}%"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fractor
|
|
4
|
+
# PriorityWork extends Work with priority levels for priority-based scheduling
|
|
5
|
+
#
|
|
6
|
+
# Priority levels:
|
|
7
|
+
# - :critical - Highest priority, processed first
|
|
8
|
+
# - :high - High priority
|
|
9
|
+
# - :normal - Default priority (backward compatible)
|
|
10
|
+
# - :low - Low priority
|
|
11
|
+
# - :background - Lowest priority
|
|
12
|
+
#
|
|
13
|
+
# @example Creating priority work
|
|
14
|
+
# work = Fractor::PriorityWork.new(data: "urgent task", priority: :high)
|
|
15
|
+
#
|
|
16
|
+
# @example Using default priority
|
|
17
|
+
# work = Fractor::PriorityWork.new(data: "normal task")
|
|
18
|
+
# work.priority # => :normal
|
|
19
|
+
class PriorityWork < Work
|
|
20
|
+
PRIORITY_LEVELS = {
|
|
21
|
+
critical: 0,
|
|
22
|
+
high: 1,
|
|
23
|
+
normal: 2,
|
|
24
|
+
low: 3,
|
|
25
|
+
background: 4,
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :priority, :created_at
|
|
29
|
+
|
|
30
|
+
# Initialize a new PriorityWork
|
|
31
|
+
#
|
|
32
|
+
# @param input [Object] The input data for the work
|
|
33
|
+
# @param priority [Symbol] Priority level (:critical, :high, :normal, :low, :background)
|
|
34
|
+
# @raise [ArgumentError] if priority is not a valid level
|
|
35
|
+
def initialize(input, priority: :normal)
|
|
36
|
+
super(input)
|
|
37
|
+
validate_priority!(priority)
|
|
38
|
+
@priority = priority
|
|
39
|
+
@created_at = Time.now
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get numeric priority value (lower is higher priority)
|
|
43
|
+
#
|
|
44
|
+
# @return [Integer] Numeric priority (0-4)
|
|
45
|
+
def priority_value
|
|
46
|
+
PRIORITY_LEVELS[@priority]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Calculate age in seconds (used for priority aging)
|
|
50
|
+
#
|
|
51
|
+
# @return [Float] Age in seconds since creation
|
|
52
|
+
def age
|
|
53
|
+
Time.now - @created_at
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Compare priorities for sorting
|
|
57
|
+
# Lower priority value = higher priority
|
|
58
|
+
# For same priority, older work comes first (FIFO within priority)
|
|
59
|
+
#
|
|
60
|
+
# @param other [PriorityWork] Other work to compare with
|
|
61
|
+
# @return [Integer] -1, 0, or 1 for comparison
|
|
62
|
+
def <=>(other)
|
|
63
|
+
return nil unless other.is_a?(PriorityWork)
|
|
64
|
+
|
|
65
|
+
# First compare by priority value
|
|
66
|
+
result = priority_value <=> other.priority_value
|
|
67
|
+
return result unless result.zero?
|
|
68
|
+
|
|
69
|
+
# If same priority, use FIFO (older first)
|
|
70
|
+
created_at <=> other.created_at
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check if this work has higher priority than another
|
|
74
|
+
#
|
|
75
|
+
# @param other [PriorityWork] Other work to compare with
|
|
76
|
+
# @return [Boolean] true if this work has higher priority
|
|
77
|
+
def higher_priority_than?(other)
|
|
78
|
+
return false unless other.is_a?(PriorityWork)
|
|
79
|
+
|
|
80
|
+
priority_value < other.priority_value
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def validate_priority!(priority)
|
|
86
|
+
return if PRIORITY_LEVELS.key?(priority)
|
|
87
|
+
|
|
88
|
+
raise ArgumentError,
|
|
89
|
+
"Invalid priority: #{priority}. " \
|
|
90
|
+
"Must be one of: #{PRIORITY_LEVELS.keys.join(', ')}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fractor
|
|
4
|
+
# PriorityWorkQueue manages work items with priority-based scheduling
|
|
5
|
+
#
|
|
6
|
+
# Features:
|
|
7
|
+
# - Priority levels from :critical to :background
|
|
8
|
+
# - FIFO within same priority level
|
|
9
|
+
# - Optional priority aging to prevent starvation
|
|
10
|
+
# - Thread-safe operations
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# queue = Fractor::PriorityWorkQueue.new
|
|
14
|
+
# queue.push(PriorityWork.new("urgent", priority: :critical))
|
|
15
|
+
# queue.push(PriorityWork.new("normal", priority: :normal))
|
|
16
|
+
# work = queue.pop # Returns critical priority work first
|
|
17
|
+
#
|
|
18
|
+
# @example With priority aging
|
|
19
|
+
# queue = Fractor::PriorityWorkQueue.new(aging_enabled: true, aging_threshold: 60)
|
|
20
|
+
class PriorityWorkQueue
|
|
21
|
+
attr_reader :aging_enabled, :aging_threshold
|
|
22
|
+
|
|
23
|
+
# Initialize a new PriorityWorkQueue
|
|
24
|
+
#
|
|
25
|
+
# @param aging_enabled [Boolean] Enable priority aging to prevent starvation
|
|
26
|
+
# @param aging_threshold [Integer] Seconds before a work item gets priority boost
|
|
27
|
+
def initialize(aging_enabled: false, aging_threshold: 60)
|
|
28
|
+
@queue = []
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
@condition = ConditionVariable.new
|
|
31
|
+
@aging_enabled = aging_enabled
|
|
32
|
+
@aging_threshold = aging_threshold
|
|
33
|
+
@closed = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Add work to the queue
|
|
37
|
+
#
|
|
38
|
+
# @param work [PriorityWork] Work item to add
|
|
39
|
+
# @raise [ArgumentError] if work is not a PriorityWork instance
|
|
40
|
+
# @raise [ClosedQueueError] if queue is closed
|
|
41
|
+
def push(work)
|
|
42
|
+
unless work.is_a?(PriorityWork)
|
|
43
|
+
raise ArgumentError,
|
|
44
|
+
"Work must be a PriorityWork"
|
|
45
|
+
end
|
|
46
|
+
raise ClosedQueueError, "Queue is closed" if @closed
|
|
47
|
+
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
@queue << work
|
|
50
|
+
sort_queue!
|
|
51
|
+
@condition.signal
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
alias << push
|
|
55
|
+
alias enqueue push
|
|
56
|
+
|
|
57
|
+
# Remove and return highest priority work
|
|
58
|
+
# Blocks if queue is empty
|
|
59
|
+
#
|
|
60
|
+
# @return [PriorityWork, nil] Highest priority work or nil if queue closed
|
|
61
|
+
def pop
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
loop do
|
|
64
|
+
return nil if @closed && @queue.empty?
|
|
65
|
+
|
|
66
|
+
unless @queue.empty?
|
|
67
|
+
apply_aging! if @aging_enabled
|
|
68
|
+
return @queue.shift
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@condition.wait(@mutex)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
alias dequeue pop
|
|
76
|
+
alias shift pop
|
|
77
|
+
|
|
78
|
+
# Try to remove and return highest priority work without blocking
|
|
79
|
+
#
|
|
80
|
+
# @return [PriorityWork, nil] Highest priority work or nil if empty
|
|
81
|
+
def pop_non_blocking
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
return nil if @queue.empty?
|
|
84
|
+
|
|
85
|
+
apply_aging! if @aging_enabled
|
|
86
|
+
@queue.shift
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Get current queue size
|
|
91
|
+
#
|
|
92
|
+
# @return [Integer] Number of items in queue
|
|
93
|
+
def size
|
|
94
|
+
@mutex.synchronize { @queue.size }
|
|
95
|
+
end
|
|
96
|
+
alias length size
|
|
97
|
+
|
|
98
|
+
# Check if queue is empty
|
|
99
|
+
#
|
|
100
|
+
# @return [Boolean] true if queue is empty
|
|
101
|
+
def empty?
|
|
102
|
+
@mutex.synchronize { @queue.empty? }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Close the queue
|
|
106
|
+
# No new items can be added, but existing items can be popped
|
|
107
|
+
def close
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
@closed = true
|
|
110
|
+
@condition.broadcast
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Check if queue is closed
|
|
115
|
+
#
|
|
116
|
+
# @return [Boolean] true if queue is closed
|
|
117
|
+
def closed?
|
|
118
|
+
@mutex.synchronize { @closed }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Clear all items from the queue
|
|
122
|
+
#
|
|
123
|
+
# @return [Array<PriorityWork>] Removed items
|
|
124
|
+
def clear
|
|
125
|
+
@mutex.synchronize do
|
|
126
|
+
items = @queue.dup
|
|
127
|
+
@queue.clear
|
|
128
|
+
items
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Get queue statistics
|
|
133
|
+
#
|
|
134
|
+
# @return [Hash] Statistics including count by priority
|
|
135
|
+
def stats
|
|
136
|
+
@mutex.synchronize do
|
|
137
|
+
priority_counts = Hash.new(0)
|
|
138
|
+
@queue.each { |work| priority_counts[work.priority] += 1 }
|
|
139
|
+
|
|
140
|
+
{
|
|
141
|
+
total: @queue.size,
|
|
142
|
+
by_priority: priority_counts,
|
|
143
|
+
oldest_age: @queue.empty? ? 0 : @queue.max_by(&:age).age,
|
|
144
|
+
closed: @closed,
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# Sort queue by priority (lower priority value = higher priority)
|
|
152
|
+
# Within same priority, maintains FIFO order (older first)
|
|
153
|
+
def sort_queue!
|
|
154
|
+
@queue.sort!
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Apply priority aging to prevent starvation
|
|
158
|
+
# Low-priority items that have waited too long get temporary boost
|
|
159
|
+
def apply_aging!
|
|
160
|
+
return unless @aging_enabled
|
|
161
|
+
|
|
162
|
+
@queue.each do |work|
|
|
163
|
+
next unless work.age >= @aging_threshold
|
|
164
|
+
|
|
165
|
+
# Boost priority by one level (but not above critical)
|
|
166
|
+
current_value = work.priority_value
|
|
167
|
+
next if current_value.zero? # Already critical
|
|
168
|
+
|
|
169
|
+
# Temporarily boost priority for sorting
|
|
170
|
+
# We don't modify the work's actual priority, just resort
|
|
171
|
+
# This is done by the natural aging factor in comparison
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Resort with aging factor considered
|
|
175
|
+
@queue.sort! do |a, b|
|
|
176
|
+
# Calculate effective priority with aging
|
|
177
|
+
a_effective = a.priority_value - (a.age / @aging_threshold).floor
|
|
178
|
+
b_effective = b.priority_value - (b.age / @aging_threshold).floor
|
|
179
|
+
|
|
180
|
+
# Clamp to valid range (0-4)
|
|
181
|
+
a_effective = [0, [4, a_effective].min].max
|
|
182
|
+
b_effective = [0, [4, b_effective].min].max
|
|
183
|
+
|
|
184
|
+
result = a_effective <=> b_effective
|
|
185
|
+
result.zero? ? a.created_at <=> b.created_at : result
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -39,5 +39,37 @@ module Fractor
|
|
|
39
39
|
errors: @errors.map(&:inspect),
|
|
40
40
|
}
|
|
41
41
|
end
|
|
42
|
+
|
|
43
|
+
# Get a summary of errors
|
|
44
|
+
# @return [Hash] Error summary with counts, categories, and other stats
|
|
45
|
+
def errors_summary
|
|
46
|
+
return {} if @errors.empty?
|
|
47
|
+
|
|
48
|
+
# Group errors by category
|
|
49
|
+
by_category = @errors.group_by do |e|
|
|
50
|
+
e.error_category || :unknown
|
|
51
|
+
end.transform_values(&:count)
|
|
52
|
+
|
|
53
|
+
# Group errors by severity
|
|
54
|
+
by_severity = @errors.group_by do |e|
|
|
55
|
+
e.error_severity || :unknown
|
|
56
|
+
end.transform_values(&:count)
|
|
57
|
+
|
|
58
|
+
# Count error types (class names)
|
|
59
|
+
error_types = @errors.map do |e|
|
|
60
|
+
e.error&.class&.name || e.error&.class || "String"
|
|
61
|
+
end.tally
|
|
62
|
+
|
|
63
|
+
# Get unique error messages (first 10)
|
|
64
|
+
unique_messages = @errors.map { |e| e.error.to_s }.uniq.first(10)
|
|
65
|
+
|
|
66
|
+
{
|
|
67
|
+
total_errors: @errors.size,
|
|
68
|
+
by_category: by_category,
|
|
69
|
+
by_severity: by_severity,
|
|
70
|
+
error_types: error_types,
|
|
71
|
+
sample_messages: unique_messages,
|
|
72
|
+
}
|
|
73
|
+
end
|
|
42
74
|
end
|
|
43
75
|
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fractor
|
|
4
|
+
# Manages the shutdown process for a Supervisor.
|
|
5
|
+
# Responsible for gracefully stopping all components in the correct order.
|
|
6
|
+
#
|
|
7
|
+
# This class extracts shutdown logic from Supervisor to follow
|
|
8
|
+
# the Single Responsibility Principle.
|
|
9
|
+
class ShutdownHandler
|
|
10
|
+
def initialize(workers, wakeup_ractor, timer_thread, performance_monitor,
|
|
11
|
+
main_loop_thread: nil, debug: false)
|
|
12
|
+
@workers = workers
|
|
13
|
+
@wakeup_ractor = wakeup_ractor
|
|
14
|
+
@timer_thread = timer_thread
|
|
15
|
+
@performance_monitor = performance_monitor
|
|
16
|
+
@main_loop_thread = main_loop_thread
|
|
17
|
+
@debug = debug
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Execute a graceful shutdown of all supervisor components.
|
|
21
|
+
# Components are stopped in the correct order to prevent issues:
|
|
22
|
+
# 1. Stop performance monitor (to stop metric collection)
|
|
23
|
+
# 2. Stop timer thread (to stop periodic wakeups)
|
|
24
|
+
# 3. Signal wakeup ractor (to unblock Ractor.select)
|
|
25
|
+
# 4. Signal all workers (to stop processing)
|
|
26
|
+
# 5. Wait for main loop thread and workers to finish
|
|
27
|
+
#
|
|
28
|
+
# @param wait_for_completion [Boolean] Whether to wait for all workers to close
|
|
29
|
+
# @param timeout [Integer] Maximum seconds to wait for shutdown (default: 10)
|
|
30
|
+
# @return [void]
|
|
31
|
+
def shutdown(wait_for_completion: false, timeout: 10)
|
|
32
|
+
stop_performance_monitor
|
|
33
|
+
stop_timer_thread
|
|
34
|
+
signal_wakeup_ractor
|
|
35
|
+
signal_all_workers
|
|
36
|
+
|
|
37
|
+
wait_for_shutdown_completion(timeout) if wait_for_completion
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Wait for all components to finish after shutdown signals have been sent.
|
|
41
|
+
# This waits for both the main loop thread (if provided) and all workers to close.
|
|
42
|
+
# This is important for tests and for ensuring clean shutdown.
|
|
43
|
+
#
|
|
44
|
+
# @param timeout [Integer] Maximum seconds to wait
|
|
45
|
+
# @return [Boolean] true if all components finished, false if timeout
|
|
46
|
+
def wait_for_shutdown_completion(timeout = 10)
|
|
47
|
+
start_time = Time.now
|
|
48
|
+
poll_interval = 0.1
|
|
49
|
+
|
|
50
|
+
loop do
|
|
51
|
+
# Check if timeout exceeded
|
|
52
|
+
break if Time.now - start_time > timeout
|
|
53
|
+
|
|
54
|
+
# Check main loop thread status (if provided and alive)
|
|
55
|
+
main_loop_done = @main_loop_thread.nil? || !@main_loop_thread.alive?
|
|
56
|
+
|
|
57
|
+
# Check if all workers are closed
|
|
58
|
+
workers_done = @workers.empty? || @workers.all?(&:closed?)
|
|
59
|
+
|
|
60
|
+
# If both main loop and workers are done, we're finished
|
|
61
|
+
if main_loop_done && workers_done
|
|
62
|
+
puts "All components closed successfully" if @debug
|
|
63
|
+
return true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Show status while waiting
|
|
67
|
+
if @debug
|
|
68
|
+
closed_count = @workers.count(&:closed?)
|
|
69
|
+
main_status = @main_loop_thread&.alive? ? "running" : "stopped"
|
|
70
|
+
puts "Waiting for shutdown: main_loop=#{main_status}, workers=#{closed_count}/#{@workers.size} closed"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
sleep(poll_interval)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Timeout exceeded
|
|
77
|
+
if @debug
|
|
78
|
+
closed_count = @workers.count(&:closed?)
|
|
79
|
+
main_status = @main_loop_thread&.alive? ? "running" : "stopped"
|
|
80
|
+
puts "Shutdown timeout: main_loop=#{main_status}, workers=#{closed_count}/#{@workers.size} closed after #{timeout}s"
|
|
81
|
+
end
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Stop the performance monitor if it's enabled.
|
|
86
|
+
#
|
|
87
|
+
# @return [void]
|
|
88
|
+
def stop_performance_monitor
|
|
89
|
+
return unless @performance_monitor
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
@performance_monitor.stop
|
|
93
|
+
puts "Performance monitor stopped" if @debug
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
puts "Error stopping performance monitor: #{e.message}" if @debug
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Wait for the timer thread to finish if it exists.
|
|
100
|
+
#
|
|
101
|
+
# @return [void]
|
|
102
|
+
def stop_timer_thread
|
|
103
|
+
return unless @timer_thread
|
|
104
|
+
|
|
105
|
+
# Only wait if thread is alive
|
|
106
|
+
if @timer_thread.alive?
|
|
107
|
+
@timer_thread.join(1) # Wait up to 1 second
|
|
108
|
+
@timer_thread.kill # Ensure thread is stopped
|
|
109
|
+
puts "Timer thread stopped" if @debug
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Signal the wakeup ractor to unblock Ractor.select.
|
|
114
|
+
# This is done first to allow the main loop to process the shutdown.
|
|
115
|
+
#
|
|
116
|
+
# @return [void]
|
|
117
|
+
def signal_wakeup_ractor
|
|
118
|
+
return unless @wakeup_ractor
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
@wakeup_ractor.send(:shutdown)
|
|
122
|
+
puts "Sent shutdown signal to wakeup ractor" if @debug
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
puts "Error sending shutdown to wakeup ractor: #{e.message}" if @debug
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Send shutdown signal to all workers.
|
|
129
|
+
# Workers should gracefully finish their current work and exit.
|
|
130
|
+
#
|
|
131
|
+
# @return [void]
|
|
132
|
+
def signal_all_workers
|
|
133
|
+
@workers.each do |w|
|
|
134
|
+
begin
|
|
135
|
+
w.send(:shutdown)
|
|
136
|
+
rescue StandardError
|
|
137
|
+
# Ignore errors when sending shutdown to workers
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
puts "Sent shutdown signal to #{w.name}" if @debug
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check if the shutdown process has completed.
|
|
145
|
+
# This is useful for testing and monitoring.
|
|
146
|
+
#
|
|
147
|
+
# @return [Boolean] true if all components are stopped
|
|
148
|
+
def complete?
|
|
149
|
+
timer_stopped = @timer_thread.nil? || !@timer_thread.alive?
|
|
150
|
+
workers_stopped = @workers.empty? || @workers.all?(&:closed?)
|
|
151
|
+
|
|
152
|
+
timer_stopped && workers_stopped
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Get a summary of the shutdown status.
|
|
156
|
+
#
|
|
157
|
+
# @return [Hash] Status summary with component states
|
|
158
|
+
def status_summary
|
|
159
|
+
{
|
|
160
|
+
performance_monitor: @performance_monitor&.send(:monitoring?) || false,
|
|
161
|
+
timer_thread: @timer_thread&.alive? || false,
|
|
162
|
+
wakeup_ractor: !@wakeup_ractor.nil?,
|
|
163
|
+
workers_count: @workers.size,
|
|
164
|
+
workers_closed: @workers.count(&:closed?),
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|