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.
Files changed (172) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +227 -102
  3. data/README.adoc +113 -1940
  4. data/docs/.lycheeignore +16 -0
  5. data/docs/Gemfile +24 -0
  6. data/docs/README.md +157 -0
  7. data/docs/_config.yml +151 -0
  8. data/docs/_features/error-handling.adoc +1192 -0
  9. data/docs/_features/index.adoc +80 -0
  10. data/docs/_features/monitoring.adoc +589 -0
  11. data/docs/_features/signal-handling.adoc +202 -0
  12. data/docs/_features/workflows.adoc +1235 -0
  13. data/docs/_guides/continuous-mode.adoc +736 -0
  14. data/docs/_guides/cookbook.adoc +1133 -0
  15. data/docs/_guides/index.adoc +55 -0
  16. data/docs/_guides/pipeline-mode.adoc +730 -0
  17. data/docs/_guides/troubleshooting.adoc +358 -0
  18. data/docs/_pages/architecture.adoc +1390 -0
  19. data/docs/_pages/core-concepts.adoc +1392 -0
  20. data/docs/_pages/design-principles.adoc +862 -0
  21. data/docs/_pages/getting-started.adoc +290 -0
  22. data/docs/_pages/installation.adoc +143 -0
  23. data/docs/_reference/api.adoc +1080 -0
  24. data/docs/_reference/error-reporting.adoc +670 -0
  25. data/docs/_reference/examples.adoc +181 -0
  26. data/docs/_reference/index.adoc +96 -0
  27. data/docs/_reference/troubleshooting.adoc +862 -0
  28. data/docs/_tutorials/complex-workflows.adoc +1022 -0
  29. data/docs/_tutorials/data-processing-pipeline.adoc +740 -0
  30. data/docs/_tutorials/first-application.adoc +384 -0
  31. data/docs/_tutorials/index.adoc +48 -0
  32. data/docs/_tutorials/long-running-services.adoc +931 -0
  33. data/docs/assets/images/favicon-16.png +0 -0
  34. data/docs/assets/images/favicon-32.png +0 -0
  35. data/docs/assets/images/favicon-48.png +0 -0
  36. data/docs/assets/images/favicon.ico +0 -0
  37. data/docs/assets/images/favicon.png +0 -0
  38. data/docs/assets/images/favicon.svg +45 -0
  39. data/docs/assets/images/fractor-icon.svg +49 -0
  40. data/docs/assets/images/fractor-logo.svg +61 -0
  41. data/docs/index.adoc +131 -0
  42. data/docs/lychee.toml +39 -0
  43. data/examples/api_aggregator/README.adoc +627 -0
  44. data/examples/api_aggregator/api_aggregator.rb +376 -0
  45. data/examples/auto_detection/README.adoc +407 -29
  46. data/examples/continuous_chat_common/message_protocol.rb +1 -1
  47. data/examples/error_reporting.rb +207 -0
  48. data/examples/file_processor/README.adoc +170 -0
  49. data/examples/file_processor/file_processor.rb +615 -0
  50. data/examples/file_processor/sample_files/invalid.csv +1 -0
  51. data/examples/file_processor/sample_files/orders.xml +24 -0
  52. data/examples/file_processor/sample_files/products.json +23 -0
  53. data/examples/file_processor/sample_files/users.csv +6 -0
  54. data/examples/hierarchical_hasher/README.adoc +629 -41
  55. data/examples/image_processor/README.adoc +610 -0
  56. data/examples/image_processor/image_processor.rb +349 -0
  57. data/examples/image_processor/processed_images/sample_10_processed.jpg.json +12 -0
  58. data/examples/image_processor/processed_images/sample_1_processed.jpg.json +12 -0
  59. data/examples/image_processor/processed_images/sample_2_processed.jpg.json +12 -0
  60. data/examples/image_processor/processed_images/sample_3_processed.jpg.json +12 -0
  61. data/examples/image_processor/processed_images/sample_4_processed.jpg.json +12 -0
  62. data/examples/image_processor/processed_images/sample_5_processed.jpg.json +12 -0
  63. data/examples/image_processor/processed_images/sample_6_processed.jpg.json +12 -0
  64. data/examples/image_processor/processed_images/sample_7_processed.jpg.json +12 -0
  65. data/examples/image_processor/processed_images/sample_8_processed.jpg.json +12 -0
  66. data/examples/image_processor/processed_images/sample_9_processed.jpg.json +12 -0
  67. data/examples/image_processor/test_images/sample_1.png +1 -0
  68. data/examples/image_processor/test_images/sample_10.png +1 -0
  69. data/examples/image_processor/test_images/sample_2.png +1 -0
  70. data/examples/image_processor/test_images/sample_3.png +1 -0
  71. data/examples/image_processor/test_images/sample_4.png +1 -0
  72. data/examples/image_processor/test_images/sample_5.png +1 -0
  73. data/examples/image_processor/test_images/sample_6.png +1 -0
  74. data/examples/image_processor/test_images/sample_7.png +1 -0
  75. data/examples/image_processor/test_images/sample_8.png +1 -0
  76. data/examples/image_processor/test_images/sample_9.png +1 -0
  77. data/examples/log_analyzer/README.adoc +662 -0
  78. data/examples/log_analyzer/log_analyzer.rb +579 -0
  79. data/examples/log_analyzer/sample_logs/apache.log +20 -0
  80. data/examples/log_analyzer/sample_logs/json.log +15 -0
  81. data/examples/log_analyzer/sample_logs/nginx.log +15 -0
  82. data/examples/log_analyzer/sample_logs/rails.log +29 -0
  83. data/examples/multi_work_type/README.adoc +576 -26
  84. data/examples/performance_monitoring.rb +120 -0
  85. data/examples/pipeline_processing/README.adoc +740 -26
  86. data/examples/pipeline_processing/pipeline_processing.rb +2 -2
  87. data/examples/priority_work_example.rb +155 -0
  88. data/examples/producer_subscriber/README.adoc +889 -46
  89. data/examples/scatter_gather/README.adoc +829 -27
  90. data/examples/simple/README.adoc +347 -0
  91. data/examples/specialized_workers/README.adoc +622 -26
  92. data/examples/specialized_workers/specialized_workers.rb +44 -8
  93. data/examples/stream_processor/README.adoc +206 -0
  94. data/examples/stream_processor/stream_processor.rb +284 -0
  95. data/examples/web_scraper/README.adoc +625 -0
  96. data/examples/web_scraper/web_scraper.rb +285 -0
  97. data/examples/workflow/README.adoc +406 -0
  98. data/examples/workflow/circuit_breaker/README.adoc +360 -0
  99. data/examples/workflow/circuit_breaker/circuit_breaker_workflow.rb +225 -0
  100. data/examples/workflow/conditional/README.adoc +483 -0
  101. data/examples/workflow/conditional/conditional_workflow.rb +215 -0
  102. data/examples/workflow/dead_letter_queue/README.adoc +374 -0
  103. data/examples/workflow/dead_letter_queue/dead_letter_queue_workflow.rb +217 -0
  104. data/examples/workflow/fan_out/README.adoc +381 -0
  105. data/examples/workflow/fan_out/fan_out_workflow.rb +202 -0
  106. data/examples/workflow/retry/README.adoc +248 -0
  107. data/examples/workflow/retry/retry_workflow.rb +195 -0
  108. data/examples/workflow/simple_linear/README.adoc +267 -0
  109. data/examples/workflow/simple_linear/simple_linear_workflow.rb +175 -0
  110. data/examples/workflow/simplified/README.adoc +329 -0
  111. data/examples/workflow/simplified/simplified_workflow.rb +222 -0
  112. data/exe/fractor +10 -0
  113. data/lib/fractor/cli.rb +288 -0
  114. data/lib/fractor/configuration.rb +307 -0
  115. data/lib/fractor/continuous_server.rb +60 -65
  116. data/lib/fractor/error_formatter.rb +72 -0
  117. data/lib/fractor/error_report_generator.rb +152 -0
  118. data/lib/fractor/error_reporter.rb +244 -0
  119. data/lib/fractor/error_statistics.rb +147 -0
  120. data/lib/fractor/execution_tracer.rb +162 -0
  121. data/lib/fractor/logger.rb +230 -0
  122. data/lib/fractor/main_loop_handler.rb +406 -0
  123. data/lib/fractor/main_loop_handler3.rb +135 -0
  124. data/lib/fractor/main_loop_handler4.rb +299 -0
  125. data/lib/fractor/performance_metrics_collector.rb +181 -0
  126. data/lib/fractor/performance_monitor.rb +215 -0
  127. data/lib/fractor/performance_report_generator.rb +202 -0
  128. data/lib/fractor/priority_work.rb +93 -0
  129. data/lib/fractor/priority_work_queue.rb +189 -0
  130. data/lib/fractor/result_aggregator.rb +32 -0
  131. data/lib/fractor/shutdown_handler.rb +168 -0
  132. data/lib/fractor/signal_handler.rb +80 -0
  133. data/lib/fractor/supervisor.rb +382 -269
  134. data/lib/fractor/supervisor_logger.rb +88 -0
  135. data/lib/fractor/version.rb +1 -1
  136. data/lib/fractor/work.rb +12 -0
  137. data/lib/fractor/work_distribution_manager.rb +151 -0
  138. data/lib/fractor/work_queue.rb +20 -0
  139. data/lib/fractor/work_result.rb +181 -9
  140. data/lib/fractor/worker.rb +73 -0
  141. data/lib/fractor/workflow/builder.rb +210 -0
  142. data/lib/fractor/workflow/chain_builder.rb +169 -0
  143. data/lib/fractor/workflow/circuit_breaker.rb +183 -0
  144. data/lib/fractor/workflow/circuit_breaker_orchestrator.rb +208 -0
  145. data/lib/fractor/workflow/circuit_breaker_registry.rb +112 -0
  146. data/lib/fractor/workflow/dead_letter_queue.rb +334 -0
  147. data/lib/fractor/workflow/execution_hooks.rb +39 -0
  148. data/lib/fractor/workflow/execution_strategy.rb +225 -0
  149. data/lib/fractor/workflow/execution_trace.rb +134 -0
  150. data/lib/fractor/workflow/helpers.rb +191 -0
  151. data/lib/fractor/workflow/job.rb +290 -0
  152. data/lib/fractor/workflow/job_dependency_validator.rb +120 -0
  153. data/lib/fractor/workflow/logger.rb +110 -0
  154. data/lib/fractor/workflow/pre_execution_context.rb +193 -0
  155. data/lib/fractor/workflow/retry_config.rb +156 -0
  156. data/lib/fractor/workflow/retry_orchestrator.rb +184 -0
  157. data/lib/fractor/workflow/retry_strategy.rb +93 -0
  158. data/lib/fractor/workflow/structured_logger.rb +30 -0
  159. data/lib/fractor/workflow/type_compatibility_validator.rb +222 -0
  160. data/lib/fractor/workflow/visualizer.rb +211 -0
  161. data/lib/fractor/workflow/workflow_context.rb +132 -0
  162. data/lib/fractor/workflow/workflow_executor.rb +669 -0
  163. data/lib/fractor/workflow/workflow_result.rb +55 -0
  164. data/lib/fractor/workflow/workflow_validator.rb +295 -0
  165. data/lib/fractor/workflow.rb +333 -0
  166. data/lib/fractor/wrapped_ractor.rb +66 -101
  167. data/lib/fractor/wrapped_ractor3.rb +161 -0
  168. data/lib/fractor/wrapped_ractor4.rb +242 -0
  169. data/lib/fractor.rb +92 -4
  170. metadata +179 -6
  171. data/tests/sample.rb.bak +0 -309
  172. data/tests/sample_working.rb.bak +0 -209
@@ -0,0 +1,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