fractor 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop-https---raw-githubusercontent-com-riboseinc-oss-guides-main-ci-rubocop-yml +552 -0
  3. data/.rubocop.yml +14 -8
  4. data/.rubocop_todo.yml +284 -43
  5. data/README.adoc +111 -950
  6. data/docs/.lycheeignore +16 -0
  7. data/docs/Gemfile +24 -0
  8. data/docs/README.md +157 -0
  9. data/docs/_config.yml +151 -0
  10. data/docs/_features/error-handling.adoc +1192 -0
  11. data/docs/_features/index.adoc +80 -0
  12. data/docs/_features/monitoring.adoc +589 -0
  13. data/docs/_features/signal-handling.adoc +202 -0
  14. data/docs/_features/workflows.adoc +1235 -0
  15. data/docs/_guides/continuous-mode.adoc +736 -0
  16. data/docs/_guides/cookbook.adoc +1133 -0
  17. data/docs/_guides/index.adoc +55 -0
  18. data/docs/_guides/pipeline-mode.adoc +730 -0
  19. data/docs/_guides/troubleshooting.adoc +358 -0
  20. data/docs/_pages/architecture.adoc +1390 -0
  21. data/docs/_pages/core-concepts.adoc +1392 -0
  22. data/docs/_pages/design-principles.adoc +862 -0
  23. data/docs/_pages/getting-started.adoc +290 -0
  24. data/docs/_pages/installation.adoc +143 -0
  25. data/docs/_reference/api.adoc +1080 -0
  26. data/docs/_reference/error-reporting.adoc +670 -0
  27. data/docs/_reference/examples.adoc +181 -0
  28. data/docs/_reference/index.adoc +96 -0
  29. data/docs/_reference/troubleshooting.adoc +862 -0
  30. data/docs/_tutorials/complex-workflows.adoc +1022 -0
  31. data/docs/_tutorials/data-processing-pipeline.adoc +740 -0
  32. data/docs/_tutorials/first-application.adoc +384 -0
  33. data/docs/_tutorials/index.adoc +48 -0
  34. data/docs/_tutorials/long-running-services.adoc +931 -0
  35. data/docs/assets/images/favicon-16.png +0 -0
  36. data/docs/assets/images/favicon-32.png +0 -0
  37. data/docs/assets/images/favicon-48.png +0 -0
  38. data/docs/assets/images/favicon.ico +0 -0
  39. data/docs/assets/images/favicon.png +0 -0
  40. data/docs/assets/images/favicon.svg +45 -0
  41. data/docs/assets/images/fractor-icon.svg +49 -0
  42. data/docs/assets/images/fractor-logo.svg +61 -0
  43. data/docs/index.adoc +131 -0
  44. data/docs/lychee.toml +39 -0
  45. data/examples/api_aggregator/README.adoc +627 -0
  46. data/examples/api_aggregator/api_aggregator.rb +376 -0
  47. data/examples/auto_detection/README.adoc +407 -29
  48. data/examples/auto_detection/auto_detection.rb +9 -9
  49. data/examples/continuous_chat_common/message_protocol.rb +53 -0
  50. data/examples/continuous_chat_fractor/README.adoc +217 -0
  51. data/examples/continuous_chat_fractor/chat_client.rb +303 -0
  52. data/examples/continuous_chat_fractor/chat_common.rb +83 -0
  53. data/examples/continuous_chat_fractor/chat_server.rb +167 -0
  54. data/examples/continuous_chat_fractor/simulate.rb +345 -0
  55. data/examples/continuous_chat_server/README.adoc +135 -0
  56. data/examples/continuous_chat_server/chat_client.rb +303 -0
  57. data/examples/continuous_chat_server/chat_server.rb +359 -0
  58. data/examples/continuous_chat_server/simulate.rb +343 -0
  59. data/examples/error_reporting.rb +207 -0
  60. data/examples/file_processor/README.adoc +170 -0
  61. data/examples/file_processor/file_processor.rb +615 -0
  62. data/examples/file_processor/sample_files/invalid.csv +1 -0
  63. data/examples/file_processor/sample_files/orders.xml +24 -0
  64. data/examples/file_processor/sample_files/products.json +23 -0
  65. data/examples/file_processor/sample_files/users.csv +6 -0
  66. data/examples/hierarchical_hasher/README.adoc +629 -41
  67. data/examples/hierarchical_hasher/hierarchical_hasher.rb +12 -8
  68. data/examples/image_processor/README.adoc +610 -0
  69. data/examples/image_processor/image_processor.rb +349 -0
  70. data/examples/image_processor/processed_images/sample_10_processed.jpg.json +12 -0
  71. data/examples/image_processor/processed_images/sample_1_processed.jpg.json +12 -0
  72. data/examples/image_processor/processed_images/sample_2_processed.jpg.json +12 -0
  73. data/examples/image_processor/processed_images/sample_3_processed.jpg.json +12 -0
  74. data/examples/image_processor/processed_images/sample_4_processed.jpg.json +12 -0
  75. data/examples/image_processor/processed_images/sample_5_processed.jpg.json +12 -0
  76. data/examples/image_processor/processed_images/sample_6_processed.jpg.json +12 -0
  77. data/examples/image_processor/processed_images/sample_7_processed.jpg.json +12 -0
  78. data/examples/image_processor/processed_images/sample_8_processed.jpg.json +12 -0
  79. data/examples/image_processor/processed_images/sample_9_processed.jpg.json +12 -0
  80. data/examples/image_processor/test_images/sample_1.png +1 -0
  81. data/examples/image_processor/test_images/sample_10.png +1 -0
  82. data/examples/image_processor/test_images/sample_2.png +1 -0
  83. data/examples/image_processor/test_images/sample_3.png +1 -0
  84. data/examples/image_processor/test_images/sample_4.png +1 -0
  85. data/examples/image_processor/test_images/sample_5.png +1 -0
  86. data/examples/image_processor/test_images/sample_6.png +1 -0
  87. data/examples/image_processor/test_images/sample_7.png +1 -0
  88. data/examples/image_processor/test_images/sample_8.png +1 -0
  89. data/examples/image_processor/test_images/sample_9.png +1 -0
  90. data/examples/log_analyzer/README.adoc +662 -0
  91. data/examples/log_analyzer/log_analyzer.rb +579 -0
  92. data/examples/log_analyzer/sample_logs/apache.log +20 -0
  93. data/examples/log_analyzer/sample_logs/json.log +15 -0
  94. data/examples/log_analyzer/sample_logs/nginx.log +15 -0
  95. data/examples/log_analyzer/sample_logs/rails.log +29 -0
  96. data/examples/multi_work_type/README.adoc +576 -26
  97. data/examples/multi_work_type/multi_work_type.rb +30 -29
  98. data/examples/performance_monitoring.rb +120 -0
  99. data/examples/pipeline_processing/README.adoc +740 -26
  100. data/examples/pipeline_processing/pipeline_processing.rb +16 -16
  101. data/examples/priority_work_example.rb +155 -0
  102. data/examples/producer_subscriber/README.adoc +889 -46
  103. data/examples/producer_subscriber/producer_subscriber.rb +20 -16
  104. data/examples/scatter_gather/README.adoc +829 -27
  105. data/examples/scatter_gather/scatter_gather.rb +29 -28
  106. data/examples/simple/README.adoc +347 -0
  107. data/examples/simple/sample.rb +5 -5
  108. data/examples/specialized_workers/README.adoc +622 -26
  109. data/examples/specialized_workers/specialized_workers.rb +88 -45
  110. data/examples/stream_processor/README.adoc +206 -0
  111. data/examples/stream_processor/stream_processor.rb +284 -0
  112. data/examples/web_scraper/README.adoc +625 -0
  113. data/examples/web_scraper/web_scraper.rb +285 -0
  114. data/examples/workflow/README.adoc +406 -0
  115. data/examples/workflow/circuit_breaker/README.adoc +360 -0
  116. data/examples/workflow/circuit_breaker/circuit_breaker_workflow.rb +225 -0
  117. data/examples/workflow/conditional/README.adoc +483 -0
  118. data/examples/workflow/conditional/conditional_workflow.rb +215 -0
  119. data/examples/workflow/dead_letter_queue/README.adoc +374 -0
  120. data/examples/workflow/dead_letter_queue/dead_letter_queue_workflow.rb +217 -0
  121. data/examples/workflow/fan_out/README.adoc +381 -0
  122. data/examples/workflow/fan_out/fan_out_workflow.rb +202 -0
  123. data/examples/workflow/retry/README.adoc +248 -0
  124. data/examples/workflow/retry/retry_workflow.rb +195 -0
  125. data/examples/workflow/simple_linear/README.adoc +267 -0
  126. data/examples/workflow/simple_linear/simple_linear_workflow.rb +175 -0
  127. data/examples/workflow/simplified/README.adoc +329 -0
  128. data/examples/workflow/simplified/simplified_workflow.rb +222 -0
  129. data/exe/fractor +10 -0
  130. data/lib/fractor/cli.rb +288 -0
  131. data/lib/fractor/configuration.rb +307 -0
  132. data/lib/fractor/continuous_server.rb +183 -0
  133. data/lib/fractor/error_formatter.rb +72 -0
  134. data/lib/fractor/error_report_generator.rb +152 -0
  135. data/lib/fractor/error_reporter.rb +244 -0
  136. data/lib/fractor/error_statistics.rb +147 -0
  137. data/lib/fractor/execution_tracer.rb +162 -0
  138. data/lib/fractor/logger.rb +230 -0
  139. data/lib/fractor/main_loop_handler.rb +406 -0
  140. data/lib/fractor/main_loop_handler3.rb +135 -0
  141. data/lib/fractor/main_loop_handler4.rb +299 -0
  142. data/lib/fractor/performance_metrics_collector.rb +181 -0
  143. data/lib/fractor/performance_monitor.rb +215 -0
  144. data/lib/fractor/performance_report_generator.rb +202 -0
  145. data/lib/fractor/priority_work.rb +93 -0
  146. data/lib/fractor/priority_work_queue.rb +189 -0
  147. data/lib/fractor/result_aggregator.rb +33 -1
  148. data/lib/fractor/shutdown_handler.rb +168 -0
  149. data/lib/fractor/signal_handler.rb +80 -0
  150. data/lib/fractor/supervisor.rb +430 -144
  151. data/lib/fractor/supervisor_logger.rb +88 -0
  152. data/lib/fractor/version.rb +1 -1
  153. data/lib/fractor/work.rb +12 -0
  154. data/lib/fractor/work_distribution_manager.rb +151 -0
  155. data/lib/fractor/work_queue.rb +88 -0
  156. data/lib/fractor/work_result.rb +181 -9
  157. data/lib/fractor/worker.rb +75 -1
  158. data/lib/fractor/workflow/builder.rb +210 -0
  159. data/lib/fractor/workflow/chain_builder.rb +169 -0
  160. data/lib/fractor/workflow/circuit_breaker.rb +183 -0
  161. data/lib/fractor/workflow/circuit_breaker_orchestrator.rb +208 -0
  162. data/lib/fractor/workflow/circuit_breaker_registry.rb +112 -0
  163. data/lib/fractor/workflow/dead_letter_queue.rb +334 -0
  164. data/lib/fractor/workflow/execution_hooks.rb +39 -0
  165. data/lib/fractor/workflow/execution_strategy.rb +225 -0
  166. data/lib/fractor/workflow/execution_trace.rb +134 -0
  167. data/lib/fractor/workflow/helpers.rb +191 -0
  168. data/lib/fractor/workflow/job.rb +290 -0
  169. data/lib/fractor/workflow/job_dependency_validator.rb +120 -0
  170. data/lib/fractor/workflow/logger.rb +110 -0
  171. data/lib/fractor/workflow/pre_execution_context.rb +193 -0
  172. data/lib/fractor/workflow/retry_config.rb +156 -0
  173. data/lib/fractor/workflow/retry_orchestrator.rb +184 -0
  174. data/lib/fractor/workflow/retry_strategy.rb +93 -0
  175. data/lib/fractor/workflow/structured_logger.rb +30 -0
  176. data/lib/fractor/workflow/type_compatibility_validator.rb +222 -0
  177. data/lib/fractor/workflow/visualizer.rb +211 -0
  178. data/lib/fractor/workflow/workflow_context.rb +132 -0
  179. data/lib/fractor/workflow/workflow_executor.rb +669 -0
  180. data/lib/fractor/workflow/workflow_result.rb +55 -0
  181. data/lib/fractor/workflow/workflow_validator.rb +295 -0
  182. data/lib/fractor/workflow.rb +333 -0
  183. data/lib/fractor/wrapped_ractor.rb +66 -91
  184. data/lib/fractor/wrapped_ractor3.rb +161 -0
  185. data/lib/fractor/wrapped_ractor4.rb +242 -0
  186. data/lib/fractor.rb +93 -3
  187. metadata +192 -6
  188. data/tests/sample.rb.bak +0 -309
  189. data/tests/sample_working.rb.bak +0 -209
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "main_loop_handler"
4
+
5
+ module Fractor
6
+ # Ruby 4.0+ specific implementation of MainLoopHandler.
7
+ # Uses Ractor::Port for worker communication.
8
+ class MainLoopHandler4 < MainLoopHandler
9
+ # Run the main event loop for Ruby 4.0+.
10
+ def run_loop
11
+ # Build mapping of response ports to workers for message routing
12
+ port_to_worker = build_port_to_worker_map
13
+
14
+ loop do
15
+ processed_count = get_processed_count
16
+
17
+ # Check loop termination condition
18
+ break unless should_continue_running?(processed_count)
19
+
20
+ log_processing_status(processed_count)
21
+
22
+ active_items = get_active_items
23
+
24
+ # Check for new work from callbacks if in continuous mode
25
+ process_work_callbacks if continuous_mode? && !work_callbacks.empty?
26
+
27
+ # Handle edge cases - break if edge case handler indicates we should
28
+ next if handle_edge_cases_with_ports(active_items, port_to_worker,
29
+ processed_count)
30
+
31
+ # Wait for next message from any active ractor or port
32
+ ready_item, message = select_from_mixed(active_items, port_to_worker)
33
+ next unless ready_item && message
34
+
35
+ # Process the received message
36
+ process_message_40(ready_item, message, port_to_worker)
37
+ end
38
+
39
+ puts "Main loop finished." if @debug
40
+
41
+ # Clean up ractors map after batch mode completion
42
+ cleanup_ractors_map unless continuous_mode?
43
+ end
44
+
45
+ # Clean up the ractors map after batch processing.
46
+ # In Ruby 4.0, we simply clear the map to allow garbage collection.
47
+ # The main loop already attempted to shut down workers properly.
48
+ #
49
+ # @return [void]
50
+ def cleanup_ractors_map
51
+ return if ractors_map.empty?
52
+
53
+ puts "Cleaning up ractors map (#{ractors_map.size} entries)..." if @debug
54
+
55
+ # Simply clear the map without trying to interact with ractors
56
+ # The main loop already attempted to shut down workers properly
57
+ ractors_map.clear
58
+
59
+ # Force garbage collection to help clean up orphaned ractors
60
+ GC.start
61
+ puts "Ractors map cleared and GC forced." if @debug
62
+ end
63
+
64
+ private
65
+
66
+ # Build mapping of response ports to workers.
67
+ # This is needed to route messages from ports back to workers.
68
+ #
69
+ # @return [Hash] Mapping of Ractor::Port => WrappedRactor
70
+ def build_port_to_worker_map
71
+ port_map = {}
72
+ ractors_map.each_value do |wrapped_ractor|
73
+ next unless wrapped_ractor.is_a?(WrappedRactor4)
74
+
75
+ port = wrapped_ractor.response_port
76
+ port_map[port] = wrapped_ractor if port
77
+ end
78
+ port_map
79
+ end
80
+
81
+ # Get list of active items for Ractor.select (Ruby 4.0+).
82
+ # Includes both response ports and ractors (excluding wakeup ractor).
83
+ #
84
+ # @return [Array] List of Ractor::Port and Ractor objects
85
+ def get_active_items
86
+ items = []
87
+
88
+ # Add response ports from all workers
89
+ ractors_map.each_value do |wrapped_ractor|
90
+ next unless wrapped_ractor.is_a?(WrappedRactor4)
91
+
92
+ port = wrapped_ractor.response_port
93
+ items << port if port
94
+ end
95
+
96
+ # Add wakeup ractor/port if in continuous mode with callbacks
97
+ if continuous_mode? && !work_callbacks.empty? && wakeup_ractor && wakeup_port
98
+ items << wakeup_port
99
+ end
100
+
101
+ items
102
+ end
103
+
104
+ # Get list of active ractors for compatibility with Ruby 3.x tests.
105
+ # In Ruby 4.0, returns the actual ractor objects (not ports).
106
+ #
107
+ # @return [Array<Ractor>] List of active Ractor objects
108
+ def get_active_ractors
109
+ ractors_map.keys.reject do |ractor|
110
+ ractor == wakeup_ractor && !(continuous_mode? && !work_callbacks.empty?)
111
+ end
112
+ end
113
+
114
+ # Check for new work from callbacks in continuous mode.
115
+ #
116
+ # @return [void]
117
+ def process_work_callbacks
118
+ work_callbacks.each do |callback|
119
+ new_work = callback.call
120
+ if new_work && !new_work.empty?
121
+ @supervisor.add_work_items(new_work)
122
+ puts "Work source provided #{new_work.size} new items" if @debug
123
+
124
+ # Distribute work to idle workers
125
+ distributed = work_distribution_manager.distribute_to_idle_workers
126
+ puts "Distributed work to #{distributed} idle workers" if @debug && distributed.positive?
127
+ end
128
+ end
129
+ end
130
+
131
+ # Handle edge cases like no active workers or empty queue.
132
+ # Overload for compatibility with tests (2-argument version).
133
+ #
134
+ # @param active_ractors [Array<Ractor>] List of active ractors
135
+ # @param processed_count [Integer] Current number of processed items
136
+ # @return [Boolean] true if should break from loop
137
+ def handle_edge_cases(active_ractors, processed_count)
138
+ # For Ruby 4.0 compatibility with tests:
139
+ # Use the active_ractors array directly since tests pass it in
140
+ # In normal operation, this would be derived from ractors_map
141
+
142
+ # In continuous mode, if no active ractors and shutting down, exit loop
143
+ if active_ractors.empty? && @shutting_down
144
+ puts "No active ractors during shutdown, exiting main loop" if @debug
145
+ return true
146
+ end
147
+
148
+ # Break if no active ractors and queue is empty, but work remains
149
+ if active_ractors.empty? && work_queue.empty? && !continuous_mode? && processed_count < total_work_count
150
+ puts "Warning: No active ractors and queue is empty, but not all work is processed. Exiting loop." if @debug
151
+ return true
152
+ end
153
+
154
+ # In continuous mode, just wait if no active ractors but keep running
155
+ if active_ractors.empty?
156
+ return true unless continuous_mode?
157
+
158
+ sleep(0.1) # Small delay to avoid CPU spinning
159
+ return false # Continue to next iteration
160
+ end
161
+
162
+ false # There are active ractors, continue the loop
163
+ end
164
+
165
+ # Handle edge cases like no active workers or empty queue (3-argument version).
166
+ #
167
+ # @param _active_items [Array] List of active ports/ractors
168
+ # @param port_to_worker [Hash] Mapping of ports to workers
169
+ # @param processed_count [Integer] Current number of processed items
170
+ # @return [Boolean] true if should break from loop
171
+ def handle_edge_cases_with_ports(_active_items, port_to_worker,
172
+ processed_count)
173
+ # Count active workers (those with response ports)
174
+ active_worker_count = port_to_worker.size
175
+
176
+ # In continuous mode, if no active workers and shutting down, exit loop
177
+ if active_worker_count.zero? && @shutting_down
178
+ puts "No active workers during shutdown, exiting main loop" if @debug
179
+ return true
180
+ end
181
+
182
+ # Break if no active workers and queue is empty, but work remains
183
+ if active_worker_count.zero? && work_queue.empty? && !continuous_mode? && processed_count < total_work_count
184
+ puts "Warning: No active workers and queue is empty, but not all work is processed. Exiting loop." if @debug
185
+ return true
186
+ end
187
+
188
+ # In continuous mode, just wait if no active workers but keep running
189
+ if active_worker_count.zero?
190
+ return true unless continuous_mode?
191
+
192
+ sleep(0.1) # Small delay to avoid CPU spinning
193
+ return false # Continue to next iteration
194
+ end
195
+
196
+ false
197
+ end
198
+
199
+ # Wait for a message from any active ractor or port (Ruby 4.0+).
200
+ # In Ruby 4.0, we select from a mix of response ports and ractors.
201
+ #
202
+ # @param active_items [Array] List of active ports/ractors
203
+ # @param port_to_worker [Hash] Mapping of ports to workers
204
+ # @return [Array] ready_item and message, or nil if should continue
205
+ def select_from_mixed(active_items, port_to_worker)
206
+ # In Ruby 4.0, we use Ractor.select on ports (and potentially ractors)
207
+ # The response ports receive :result and :error messages
208
+ # The wakeup ractor (if present) receives wakeup signals
209
+
210
+ return nil, nil if active_items.empty?
211
+
212
+ ready_item, message = Ractor.select(*active_items)
213
+
214
+ # Check if this is the wakeup port
215
+ if ready_item == wakeup_port
216
+ puts "Wakeup signal received: #{message[:message]}" if @debug
217
+ # Remove wakeup ractor from map if shutting down
218
+ if message && message[:message] == :shutdown
219
+ ractors_map.delete(wakeup_ractor)
220
+ @supervisor.instance_variable_set(:@wakeup_ractor, nil)
221
+ end
222
+ # Return nil to indicate we should continue to next iteration
223
+ return nil, nil
224
+ end
225
+
226
+ [ready_item, message]
227
+ rescue Ractor::ClosedError, Ractor::Error => e
228
+ # Handle closed ports/ractors - remove them from ractors_map
229
+ puts "Ractor::Error in select: #{e.message}. Cleaning up closed ports." if @debug
230
+
231
+ # Find and remove workers with closed ports
232
+ closed_ports = active_items.select { |item| item.is_a?(Ractor::Port) }
233
+ closed_ports.each do |port|
234
+ wrapped_ractor = port_to_worker[port]
235
+ if wrapped_ractor
236
+ puts "Removing worker with closed port: #{wrapped_ractor.name}" if @debug
237
+ ractors_map.delete(wrapped_ractor.ractor)
238
+ workers.delete(wrapped_ractor)
239
+ port_to_worker.delete(port)
240
+ end
241
+ end
242
+
243
+ # Return nil to continue the loop with updated active_items
244
+ [nil, nil]
245
+ end
246
+
247
+ # Process a message from a ractor or port (Ruby 4.0+).
248
+ # Most messages come through response ports in Ruby 4.0.
249
+ #
250
+ # @param ready_item [Ractor::Port, Ractor] The port or ractor that sent the message
251
+ # @param message [Hash] The message received
252
+ # @param port_to_worker [Hash] Mapping of ports to workers
253
+ # @return [void]
254
+ def process_message_40(ready_item, message, port_to_worker)
255
+ # Find the corresponding WrappedRactor instance
256
+ if ready_item.is_a?(Ractor::Port)
257
+ # Message from a response port - look up worker
258
+ wrapped_ractor = port_to_worker[ready_item]
259
+ unless wrapped_ractor
260
+ puts "Warning: Received message from unknown port: #{ready_item}. Ignoring." if @debug
261
+ return
262
+ end
263
+ else
264
+ # Message from a ractor (e.g., initialize, shutdown acknowledgment)
265
+ wrapped_ractor = ractors_map[ready_item]
266
+ unless wrapped_ractor
267
+ puts "Warning: Received message from unknown Ractor: #{ready_item}. Ignoring." if @debug
268
+ ractors_map.delete(ready_item)
269
+ return
270
+ end
271
+ end
272
+
273
+ # Guard against nil messages (indicates closed port/ractor)
274
+ if message.nil?
275
+ puts "Warning: Received nil message from #{wrapped_ractor.name}. Port/Ractor likely closed." if @debug
276
+ ractors_map.delete(wrapped_ractor.ractor)
277
+ workers.delete(wrapped_ractor)
278
+ port_to_worker.delete(ready_item) if ready_item.is_a?(Ractor::Port)
279
+ return
280
+ end
281
+
282
+ puts "Selected from: #{wrapped_ractor.name}, Message Type: #{message[:type]}" if @debug
283
+
284
+ # Route to appropriate message handler
285
+ case message[:type]
286
+ when :initialize
287
+ handle_initialize_message(wrapped_ractor)
288
+ when :shutdown
289
+ handle_shutdown_message(wrapped_ractor.ractor, wrapped_ractor)
290
+ when :result
291
+ handle_result_message(wrapped_ractor, message)
292
+ when :error
293
+ handle_error_message(wrapped_ractor, message)
294
+ else
295
+ puts "Unknown message type received: #{message[:type]} from #{wrapped_ractor.name}" if @debug
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fractor
4
+ # Internal metrics collector for performance monitoring.
5
+ # Thread-safe collection of performance metrics.
6
+ class PerformanceMetricsCollector
7
+ attr_reader :jobs_processed, :jobs_succeeded, :jobs_failed, :total_latency
8
+
9
+ def initialize
10
+ reset
11
+ end
12
+
13
+ # Reset all metrics to initial state
14
+ def reset
15
+ @jobs_processed = 0
16
+ @jobs_succeeded = 0
17
+ @jobs_failed = 0
18
+ @latencies = []
19
+ @total_latency = 0.0
20
+ @queue_depths = []
21
+ @memory_samples = []
22
+ @utilization_samples = []
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # Record a job completion with its latency
27
+ #
28
+ # @param latency [Float] Job latency in seconds
29
+ # @param success [Boolean] Whether job succeeded
30
+ # @return [void]
31
+ def record_job(latency, success: true)
32
+ @mutex.synchronize do
33
+ @jobs_processed += 1
34
+ @jobs_succeeded += 1 if success
35
+ @jobs_failed += 1 unless success
36
+ @latencies << latency
37
+ @total_latency += latency
38
+ end
39
+ end
40
+
41
+ # Sample the current queue depth
42
+ #
43
+ # @param depth [Integer] Current queue depth
44
+ # @return [void]
45
+ def sample_queue_depth(depth)
46
+ @mutex.synchronize do
47
+ @queue_depths << depth
48
+ end
49
+ end
50
+
51
+ # Sample current memory usage
52
+ #
53
+ # @param mb [Float] Memory usage in MB
54
+ # @return [void]
55
+ def sample_memory(mb)
56
+ @mutex.synchronize do
57
+ @memory_samples << mb
58
+ end
59
+ end
60
+
61
+ # Sample worker utilization ratio
62
+ #
63
+ # @param ratio [Float] Worker utilization (0.0 to 1.0)
64
+ # @return [void]
65
+ def sample_worker_utilization(ratio)
66
+ @mutex.synchronize do
67
+ @utilization_samples << ratio
68
+ end
69
+ end
70
+
71
+ # Calculate average latency
72
+ #
73
+ # @return [Float] Average latency in seconds
74
+ def average_latency
75
+ @mutex.synchronize do
76
+ average_latency_unsynchronized
77
+ end
78
+ end
79
+
80
+ # Calculate latency percentile
81
+ #
82
+ # @param p [Integer] Percentile (0-100)
83
+ # @return [Float] Latency at percentile in seconds
84
+ def percentile(p)
85
+ @mutex.synchronize do
86
+ return 0.0 if @latencies.empty?
87
+
88
+ sorted = @latencies.sort
89
+ index = ((p / 100.0) * sorted.size).ceil - 1
90
+ sorted[[index, 0].max]
91
+ end
92
+ end
93
+
94
+ # Calculate average queue depth
95
+ #
96
+ # @return [Float] Average queue depth
97
+ def average_queue_depth
98
+ @mutex.synchronize do
99
+ return 0.0 if @queue_depths.empty?
100
+
101
+ @queue_depths.sum / @queue_depths.size.to_f
102
+ end
103
+ end
104
+
105
+ # Get maximum queue depth observed
106
+ #
107
+ # @return [Integer] Maximum queue depth
108
+ def max_queue_depth
109
+ @mutex.synchronize do
110
+ return 0 if @queue_depths.empty?
111
+
112
+ @queue_depths.max
113
+ end
114
+ end
115
+
116
+ # Calculate enqueue rate (jobs per second)
117
+ #
118
+ # @param duration [Float] Time period in seconds
119
+ # @return [Float] Enqueue rate
120
+ def enqueue_rate(duration)
121
+ return 0.0 if duration <= 0
122
+
123
+ @jobs_processed / duration.to_f
124
+ end
125
+
126
+ # Calculate dequeue rate (jobs per second)
127
+ #
128
+ # @param duration [Float] Time period in seconds
129
+ # @return [Float] Dequeue rate
130
+ def dequeue_rate(duration)
131
+ return 0.0 if duration <= 0
132
+
133
+ @jobs_processed / duration.to_f
134
+ end
135
+
136
+ # Calculate average wait time using Little's Law
137
+ #
138
+ # @return [Float] Average wait time in seconds
139
+ def average_wait_time
140
+ # Wait time approximation based on queue depth and throughput
141
+ @mutex.synchronize do
142
+ return 0.0 if @queue_depths.empty? || @latencies.empty?
143
+
144
+ avg_depth = @queue_depths.sum / @queue_depths.size.to_f
145
+ avg_lat = @total_latency / @latencies.size
146
+ return 0.0 if avg_lat.zero?
147
+
148
+ # Little's Law: Wait Time ≈ Queue Length / Throughput
149
+ avg_depth * avg_lat
150
+ end
151
+ end
152
+
153
+ # Calculate wait time at a given percentile
154
+ #
155
+ # @param p [Integer] Percentile (0-100)
156
+ # @return [Float] Wait time at percentile in seconds
157
+ def wait_time_percentile(p)
158
+ # Simplified wait time percentile based on queue depth percentile
159
+ @mutex.synchronize do
160
+ return 0.0 if @queue_depths.empty?
161
+
162
+ sorted = @queue_depths.sort
163
+ index = ((p / 100.0) * sorted.size).ceil - 1
164
+ depth_percentile = sorted[[index, 0].max]
165
+
166
+ avg_lat = @total_latency / @latencies.size
167
+ return 0.0 if avg_lat.zero?
168
+
169
+ depth_percentile * avg_lat
170
+ end
171
+ end
172
+
173
+ private
174
+
175
+ def average_latency_unsynchronized
176
+ return 0.0 if @latencies.empty?
177
+
178
+ @total_latency / @latencies.size
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "performance_metrics_collector"
4
+ require_relative "performance_report_generator"
5
+
6
+ module Fractor
7
+ # Monitors and tracks performance metrics for Fractor supervisors and workers.
8
+ #
9
+ # Collects metrics including:
10
+ # - Jobs processed count
11
+ # - Latency statistics (average, p50, p95, p99)
12
+ # - Throughput (jobs/second)
13
+ # - Worker utilization
14
+ # - Queue depth over time
15
+ # - Memory usage
16
+ #
17
+ # @example Basic usage
18
+ # supervisor = Fractor::Supervisor.new(...)
19
+ # monitor = Fractor::PerformanceMonitor.new(supervisor)
20
+ # monitor.start
21
+ #
22
+ # # ... run workload ...
23
+ #
24
+ # monitor.stop
25
+ # puts monitor.report
26
+ #
27
+ # @example With custom sampling interval
28
+ # monitor = Fractor::PerformanceMonitor.new(
29
+ # supervisor,
30
+ # sample_interval: 0.5 # Sample every 500ms
31
+ # )
32
+ class PerformanceMonitor
33
+ attr_reader :supervisor, :metrics, :start_time, :end_time
34
+
35
+ # Create a new performance monitor
36
+ #
37
+ # @param supervisor [Supervisor] The supervisor to monitor
38
+ # @param sample_interval [Float] How often to sample metrics (seconds)
39
+ def initialize(supervisor, sample_interval: 1.0)
40
+ @supervisor = supervisor
41
+ @sample_interval = sample_interval
42
+ @metrics = PerformanceMetricsCollector.new
43
+ @start_time = nil
44
+ @end_time = nil
45
+ @monitoring = false
46
+ @monitor_thread = nil
47
+ end
48
+
49
+ # Start monitoring
50
+ #
51
+ # @return [void]
52
+ def start
53
+ return if @monitoring
54
+
55
+ @monitoring = true
56
+ @start_time = Time.now
57
+ @metrics.reset
58
+
59
+ # Start background monitoring thread
60
+ @monitor_thread = Thread.new { monitor_loop }
61
+ end
62
+
63
+ # Stop monitoring
64
+ #
65
+ # @return [void]
66
+ def stop
67
+ return unless @monitoring
68
+
69
+ @monitoring = false
70
+ @end_time = Time.now
71
+ @monitor_thread&.join
72
+ @monitor_thread = nil
73
+ end
74
+
75
+ # Check if currently monitoring
76
+ #
77
+ # @return [Boolean]
78
+ def monitoring?
79
+ @monitoring
80
+ end
81
+
82
+ # Get current metrics snapshot
83
+ #
84
+ # @return [Hash] Current metrics
85
+ def snapshot
86
+ {
87
+ jobs_processed: @metrics.jobs_processed,
88
+ jobs_succeeded: @metrics.jobs_succeeded,
89
+ jobs_failed: @metrics.jobs_failed,
90
+ average_latency: @metrics.average_latency,
91
+ p50_latency: @metrics.percentile(50),
92
+ p95_latency: @metrics.percentile(95),
93
+ p99_latency: @metrics.percentile(99),
94
+ throughput: calculate_throughput,
95
+ queue_depth: current_queue_depth,
96
+ queue_depth_avg: @metrics.average_queue_depth,
97
+ queue_depth_max: @metrics.max_queue_depth,
98
+ enqueue_rate: @metrics.enqueue_rate(uptime),
99
+ dequeue_rate: @metrics.dequeue_rate(uptime),
100
+ average_wait_time: @metrics.average_wait_time,
101
+ p50_wait_time: @metrics.wait_time_percentile(50),
102
+ p95_wait_time: @metrics.wait_time_percentile(95),
103
+ p99_wait_time: @metrics.wait_time_percentile(99),
104
+ worker_count: worker_count,
105
+ active_workers: active_worker_count,
106
+ worker_utilization: worker_utilization,
107
+ memory_mb: current_memory_mb,
108
+ uptime: uptime,
109
+ }
110
+ end
111
+
112
+ # Generate a human-readable report
113
+ #
114
+ # @return [String] Formatted report
115
+ def report
116
+ PerformanceReportGenerator.generate_report(snapshot)
117
+ end
118
+
119
+ # Export metrics in JSON format
120
+ #
121
+ # @return [String] JSON representation
122
+ def to_json(*_args)
123
+ PerformanceReportGenerator.to_json(snapshot)
124
+ end
125
+
126
+ # Export metrics in Prometheus format
127
+ #
128
+ # @return [String] Prometheus metrics
129
+ def to_prometheus
130
+ stats = snapshot
131
+ PerformanceReportGenerator.to_prometheus(stats, @metrics.total_latency)
132
+ end
133
+
134
+ # Record a job completion
135
+ #
136
+ # @param latency [Float] Job latency in seconds
137
+ # @param success [Boolean] Whether job succeeded
138
+ # @return [void]
139
+ def record_job(latency, success: true)
140
+ @metrics.record_job(latency, success: success)
141
+ end
142
+
143
+ private
144
+
145
+ def monitor_loop
146
+ while @monitoring
147
+ sample_metrics
148
+ sleep(@sample_interval)
149
+ end
150
+ rescue StandardError => e
151
+ warn "Performance monitor error: #{e.message}"
152
+ end
153
+
154
+ def sample_metrics
155
+ @metrics.sample_queue_depth(current_queue_depth)
156
+ @metrics.sample_memory(current_memory_mb)
157
+ @metrics.sample_worker_utilization(worker_utilization)
158
+ end
159
+
160
+ def calculate_throughput
161
+ duration = uptime
162
+ return 0.0 if duration <= 0
163
+
164
+ @metrics.jobs_processed / duration.to_f
165
+ end
166
+
167
+ def uptime
168
+ end_time = @end_time || Time.now
169
+ return 0 unless @start_time
170
+
171
+ end_time - @start_time
172
+ end
173
+
174
+ def current_queue_depth
175
+ @supervisor.work_queue.size
176
+ rescue StandardError
177
+ 0
178
+ end
179
+
180
+ def worker_count
181
+ @supervisor.worker_pools.sum { |pool| pool[:num_workers] || 1 }
182
+ rescue StandardError
183
+ 0
184
+ end
185
+
186
+ def active_worker_count
187
+ # This would need worker state tracking
188
+ # For now, estimate based on queue depth
189
+ depth = current_queue_depth
190
+ total = worker_count
191
+ return total if depth.positive?
192
+
193
+ 0
194
+ end
195
+
196
+ def worker_utilization
197
+ total = worker_count
198
+ return 0.0 if total.zero?
199
+
200
+ active = active_worker_count
201
+ active.to_f / total
202
+ end
203
+
204
+ def current_memory_mb
205
+ # Get current process memory usage in MB
206
+ if RUBY_PLATFORM.match?(/darwin|linux/)
207
+ `ps -o rss= -p #{Process.pid}`.to_i / 1024.0
208
+ else
209
+ 0.0 # Unsupported platform
210
+ end
211
+ rescue StandardError
212
+ 0.0
213
+ end
214
+ end
215
+ end