fractor 0.1.4 → 0.1.6

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 (33) 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 +162 -46
  5. data/README.adoc +1364 -376
  6. data/examples/auto_detection/auto_detection.rb +9 -9
  7. data/examples/continuous_chat_common/message_protocol.rb +53 -0
  8. data/examples/continuous_chat_fractor/README.adoc +217 -0
  9. data/examples/continuous_chat_fractor/chat_client.rb +303 -0
  10. data/examples/continuous_chat_fractor/chat_common.rb +83 -0
  11. data/examples/continuous_chat_fractor/chat_server.rb +167 -0
  12. data/examples/continuous_chat_fractor/simulate.rb +345 -0
  13. data/examples/continuous_chat_server/README.adoc +135 -0
  14. data/examples/continuous_chat_server/chat_client.rb +303 -0
  15. data/examples/continuous_chat_server/chat_server.rb +359 -0
  16. data/examples/continuous_chat_server/simulate.rb +343 -0
  17. data/examples/hierarchical_hasher/hierarchical_hasher.rb +12 -8
  18. data/examples/multi_work_type/multi_work_type.rb +30 -29
  19. data/examples/pipeline_processing/pipeline_processing.rb +15 -15
  20. data/examples/producer_subscriber/producer_subscriber.rb +20 -16
  21. data/examples/scatter_gather/scatter_gather.rb +29 -28
  22. data/examples/simple/sample.rb +5 -5
  23. data/examples/specialized_workers/specialized_workers.rb +44 -37
  24. data/lib/fractor/continuous_server.rb +188 -0
  25. data/lib/fractor/result_aggregator.rb +1 -1
  26. data/lib/fractor/supervisor.rb +277 -104
  27. data/lib/fractor/version.rb +1 -1
  28. data/lib/fractor/work_queue.rb +68 -0
  29. data/lib/fractor/work_result.rb +1 -1
  30. data/lib/fractor/worker.rb +2 -1
  31. data/lib/fractor/wrapped_ractor.rb +12 -2
  32. data/lib/fractor.rb +2 -0
  33. metadata +15 -2
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "socket"
5
+ require "json"
6
+ require "time"
7
+ require "fileutils"
8
+ require_relative "../continuous_chat_common/message_protocol"
9
+
10
+ # Simple Chat Server using Ruby's standard socket library
11
+ # Based on the approach from https://dev.to/aurelieverrot/create-a-chat-in-the-command-line-with-ruby-2po9
12
+ # but modified to use JSON for message passing.
13
+ puts "Starting chat server..."
14
+
15
+ # Parse command line args
16
+ port = ARGV[0]&.to_i || 3000
17
+ log_file_path = ARGV[1] || "logs/server_messages.log"
18
+
19
+ # Create logs directory if it doesn't exist
20
+ FileUtils.mkdir_p(File.dirname(log_file_path))
21
+ log_file = File.open(log_file_path, "w")
22
+
23
+ def log_message(message, log_file)
24
+ timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S.%L")
25
+ log_entry = "[#{timestamp}] #{message}"
26
+
27
+ # Check if log file is still open before writing
28
+ if log_file && !log_file.closed?
29
+ log_file.puts(log_entry)
30
+ log_file.flush # Ensure it's written immediately
31
+ end
32
+
33
+ # Also print to console for debugging
34
+ puts log_entry
35
+ end
36
+
37
+ # Create the server socket
38
+ server = TCPServer.new("0.0.0.0", port)
39
+ log_message("Server started on port #{port}", log_file)
40
+ puts "Server bound to port #{port}"
41
+
42
+ # Array to store connected clients
43
+ clients = {}
44
+
45
+ # Broadcast a message to all clients
46
+ def announce_to_everyone(clients, message, log_file)
47
+ log_message("Broadcasting: #{message}", log_file)
48
+
49
+ # Convert to JSON if it's not already a string
50
+ message_json = message.is_a?(String) ? message : JSON.generate(message)
51
+
52
+ # Create a copy of the clients hash to avoid modification during iteration
53
+ clients_copy = clients.dup
54
+
55
+ clients_copy.each do |username, client|
56
+ client.puts(message_json)
57
+ rescue StandardError => e
58
+ log_message("Error broadcasting to #{username}: #{e.message}", log_file)
59
+ # We don't disconnect here, that's handled in the client thread
60
+ end
61
+ end
62
+
63
+ # Handle a new client joining the chat
64
+ def handle_new_client(clients, client, log_file)
65
+ line = client.gets&.chomp
66
+ return client.close unless line
67
+
68
+ log_message("Received join message: #{line}", log_file)
69
+
70
+ # Parse the join message
71
+ message = JSON.parse(line)
72
+
73
+ if message["type"] == "join" && message["data"] && message["data"]["username"]
74
+ username = message["data"]["username"]
75
+
76
+ # Store the client with username as key
77
+ clients[username] = client
78
+
79
+ # Send welcome message to the client
80
+ welcome_msg = {
81
+ type: "server_message",
82
+ data: {
83
+ message: "Hello #{username}! Connected clients: #{clients.count}",
84
+ },
85
+ timestamp: Time.now.to_i,
86
+ }
87
+ client.puts(JSON.generate(welcome_msg))
88
+
89
+ # Broadcast to all clients that a new user joined
90
+ join_msg = {
91
+ type: "server_message",
92
+ data: {
93
+ message: "#{username} joined the chat!",
94
+ },
95
+ timestamp: Time.now.to_i,
96
+ }
97
+ announce_to_everyone(clients, join_msg, log_file)
98
+
99
+ # Broadcast the updated user list
100
+ user_list_msg = {
101
+ type: "user_list",
102
+ data: {
103
+ users: clients.keys,
104
+ },
105
+ timestamp: Time.now.to_i,
106
+ }
107
+ announce_to_everyone(clients, user_list_msg, log_file)
108
+
109
+ true
110
+ else
111
+ # Invalid join message
112
+ error_msg = {
113
+ type: "error",
114
+ data: {
115
+ message: "First message must be a valid join command",
116
+ },
117
+ timestamp: Time.now.to_i,
118
+ }
119
+ client.puts(JSON.generate(error_msg))
120
+ client.close
121
+ false
122
+ end
123
+ rescue JSON::ParserError => e
124
+ log_message("Error parsing initial join message: #{e.message}", log_file)
125
+ client.puts(JSON.generate({
126
+ type: "error",
127
+ data: {
128
+ message: "Invalid JSON format in join message",
129
+ },
130
+ timestamp: Time.now.to_i,
131
+ }))
132
+ client.close
133
+ false
134
+ rescue StandardError => e
135
+ log_message("Error handling new client: #{e.message}", log_file)
136
+ client.close
137
+ false
138
+ end
139
+
140
+ # Process a message from an existing client
141
+ def process_client_message(clients, client, username, log_file)
142
+ # Read a message from the client (non-blocking with timeout)
143
+ readable, = IO.select([client], nil, nil, 0)
144
+ return true unless readable # No data to read yet
145
+
146
+ line = client.gets&.chomp
147
+ return false unless line # Client disconnected
148
+
149
+ begin
150
+ message = JSON.parse(line)
151
+ log_message("Received from #{username}: #{line}", log_file)
152
+
153
+ case message["type"]
154
+ when "message"
155
+ content = message["data"]["content"]
156
+ recipient = message["data"]["recipient"] || "all"
157
+
158
+ if content.start_with?("/")
159
+ # Handle commands
160
+ case content
161
+ when "/list"
162
+ list_msg = {
163
+ type: "user_list",
164
+ data: {
165
+ users: clients.keys,
166
+ },
167
+ timestamp: Time.now.to_i,
168
+ }
169
+ client.puts(JSON.generate(list_msg))
170
+ else
171
+ # Unknown command
172
+ client.puts(JSON.generate({
173
+ type: "error",
174
+ data: {
175
+ message: "Unknown command: #{content}",
176
+ },
177
+ timestamp: Time.now.to_i,
178
+ }))
179
+ end
180
+ elsif recipient == "all"
181
+ # Broadcast message
182
+ broadcast_msg = {
183
+ type: "broadcast",
184
+ data: {
185
+ from: username,
186
+ content: content,
187
+ },
188
+ timestamp: Time.now.to_i,
189
+ }
190
+ announce_to_everyone(clients, broadcast_msg, log_file)
191
+ elsif clients[recipient]
192
+ # Direct message
193
+ dm_msg = {
194
+ type: "direct_message",
195
+ data: {
196
+ from: username,
197
+ content: content,
198
+ },
199
+ timestamp: Time.now.to_i,
200
+ }
201
+ clients[recipient].puts(JSON.generate(dm_msg))
202
+ # Also send to sender if not the same person
203
+ client.puts(JSON.generate(dm_msg)) if username != recipient
204
+ else
205
+ # Recipient not found
206
+ client.puts(JSON.generate({
207
+ type: "error",
208
+ data: {
209
+ message: "User #{recipient} not found",
210
+ },
211
+ timestamp: Time.now.to_i,
212
+ }))
213
+ end
214
+ when "leave"
215
+ # Client wants to leave
216
+ return false
217
+ end
218
+ rescue JSON::ParserError => e
219
+ log_message("Error parsing JSON from #{username}: #{e.message}", log_file)
220
+ client.puts(JSON.generate({
221
+ type: "error",
222
+ data: {
223
+ message: "Invalid JSON format",
224
+ },
225
+ timestamp: Time.now.to_i,
226
+ }))
227
+ rescue StandardError => e
228
+ log_message("Error processing message from #{username}: #{e.message}",
229
+ log_file)
230
+ return false
231
+ end
232
+
233
+ true # Client still connected
234
+ end
235
+
236
+ # Handle client disconnection
237
+ def handle_client_disconnect(clients, client, log_file)
238
+ username = clients.key(client)
239
+ return unless username
240
+
241
+ # Remove from clients list
242
+ clients.delete(username)
243
+ log_message("Client disconnected: #{username}", log_file)
244
+
245
+ # Notify everyone
246
+ leave_msg = {
247
+ type: "server_message",
248
+ data: {
249
+ message: "#{username} left the chat.",
250
+ },
251
+ timestamp: Time.now.to_i,
252
+ }
253
+ announce_to_everyone(clients, leave_msg, log_file)
254
+
255
+ # Update user list
256
+ user_list_msg = {
257
+ type: "user_list",
258
+ data: {
259
+ users: clients.keys,
260
+ },
261
+ timestamp: Time.now.to_i,
262
+ }
263
+ announce_to_everyone(clients, user_list_msg, log_file)
264
+
265
+ # Close the client socket
266
+ begin
267
+ client.close
268
+ rescue StandardError
269
+ nil
270
+ end
271
+ end
272
+
273
+ # Main server loop
274
+ begin
275
+ log_message("Server ready to accept connections", log_file)
276
+
277
+ # Add the server socket to the list of sockets to monitor
278
+ sockets = [server]
279
+
280
+ # Main event loop
281
+ loop do
282
+ # Use IO.select to check which sockets have data to read
283
+ # This is non-blocking and allows us to handle multiple clients sequentially
284
+ readable, _, errored = IO.select(sockets, [], sockets, 0.1)
285
+
286
+ # Handle errors first
287
+ if errored && !errored.empty?
288
+ errored.each do |socket|
289
+ raise "Server socket error" if socket == server
290
+
291
+ # Server socket error - critical
292
+
293
+ # Client socket error
294
+ username = clients.key(socket)
295
+ log_message("Error on client socket: #{username || 'unknown'}",
296
+ log_file)
297
+ handle_client_disconnect(clients, socket, log_file)
298
+ sockets.delete(socket)
299
+ end
300
+ end
301
+
302
+ # Nothing to process this iteration
303
+ next unless readable
304
+
305
+ # Process readable sockets
306
+ readable.each do |socket|
307
+ if socket == server
308
+ # New client connection
309
+ begin
310
+ client = server.accept
311
+ log_message(
312
+ "New client connection from #{client.peeraddr[2]}:#{client.peeraddr[1]}", log_file
313
+ )
314
+
315
+ # Add the client socket to our monitoring list
316
+ sockets << client
317
+
318
+ # We'll process the initial join message in the next iteration
319
+ rescue StandardError => e
320
+ log_message("Error accepting client: #{e.message}", log_file)
321
+ end
322
+ elsif clients.key(socket)
323
+ # Existing client sent data
324
+ username = clients.key(socket)
325
+
326
+ # Process message, remove client if it disconnected
327
+ unless process_client_message(clients, socket, username, log_file)
328
+ handle_client_disconnect(clients, socket, log_file)
329
+ sockets.delete(socket)
330
+ end
331
+ else
332
+ # This is a new client that needs to send their join message
333
+ unless handle_new_client(clients, socket, log_file)
334
+ # Join failed, remove from sockets
335
+ sockets.delete(socket)
336
+ end
337
+ end
338
+ end
339
+ end
340
+ rescue Interrupt
341
+ log_message("Server interrupted, shutting down...", log_file)
342
+ rescue StandardError => e
343
+ log_message("Server error: #{e.message}", log_file)
344
+ ensure
345
+ # Close all client connections
346
+ clients.each_value do |client|
347
+ client.close
348
+ rescue StandardError
349
+ # Ignore errors when closing
350
+ end
351
+
352
+ # Close the server socket
353
+ server&.close
354
+
355
+ # Close the log file
356
+ log_file&.close
357
+
358
+ log_message("Server stopped", log_file)
359
+ end
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "optparse"
6
+ require "json"
7
+
8
+ module ContinuousChat
9
+ # Simulation controller that manages the server and clients
10
+ class Simulation
11
+ attr_reader :server_port, :log_dir
12
+
13
+ def initialize(server_port = 3000, duration = 10, log_dir = "logs")
14
+ @server_port = server_port
15
+ @duration = duration
16
+ @log_dir = log_dir
17
+ @server_pid = nil
18
+ @client_pids = {}
19
+ @running = false
20
+
21
+ # Create log directory if it doesn't exist
22
+ FileUtils.mkdir_p(@log_dir)
23
+ end
24
+
25
+ # Start the simulation
26
+ def start
27
+ puts "Starting chat simulation on port #{@server_port}"
28
+ puts "Logs will be saved to #{@log_dir}"
29
+
30
+ # Start the server
31
+ start_server
32
+
33
+ # Give the server time to initialize
34
+ puts "Waiting for server to initialize..."
35
+ sleep(2)
36
+
37
+ # Start the clients
38
+ start_clients
39
+
40
+ @running = true
41
+ puts "Chat simulation started"
42
+
43
+ # Wait for the specified duration
44
+ puts "Simulation will run for #{@duration} seconds"
45
+
46
+ # Give clients time to connect
47
+ sleep(2)
48
+ puts "Clients should be connecting now..."
49
+
50
+ # Wait for messages to be processed
51
+ remaining_time = @duration - 4
52
+ if remaining_time.positive?
53
+ puts "Waiting #{remaining_time} more seconds for processing..."
54
+ sleep(remaining_time)
55
+ end
56
+
57
+ puts "Simulation time complete, stopping..."
58
+
59
+ # Stop the simulation
60
+ stop
61
+
62
+ # Analyze the logs
63
+ analyze_logs
64
+
65
+ true
66
+ rescue StandardError => e
67
+ puts "Failed to start simulation: #{e.message}"
68
+ stop
69
+ false
70
+ end
71
+
72
+ # Stop the simulation
73
+ def stop
74
+ puts "Stopping chat simulation..."
75
+
76
+ # Stop all clients
77
+ stop_clients
78
+
79
+ # Stop the server
80
+ stop_server
81
+
82
+ @running = false
83
+ puts "Chat simulation stopped"
84
+ end
85
+
86
+ private
87
+
88
+ # Start the server process
89
+ def start_server
90
+ server_log_file = File.join(@log_dir, "server_messages.log")
91
+
92
+ # Get the directory where this script is located
93
+ script_dir = File.dirname(__FILE__)
94
+ server_script = File.join(script_dir, "chat_server.rb")
95
+
96
+ server_cmd = "ruby #{server_script} #{@server_port} #{server_log_file}"
97
+
98
+ puts "Starting server: #{server_cmd}"
99
+
100
+ # Start the server process as a fork
101
+ @server_pid = fork do
102
+ exec(server_cmd)
103
+ end
104
+
105
+ puts "Server started with PID #{@server_pid}"
106
+ end
107
+
108
+ # Stop the server process
109
+ def stop_server
110
+ return unless @server_pid
111
+
112
+ puts "Stopping server (PID #{@server_pid})..."
113
+
114
+ # Send SIGINT to the server process
115
+ begin
116
+ Process.kill("INT", @server_pid)
117
+ # Give it a moment to shut down gracefully
118
+ sleep(1)
119
+
120
+ # Force kill if still running
121
+ Process.kill("KILL", @server_pid) if process_running?(@server_pid)
122
+ rescue Errno::ESRCH
123
+ # Process already gone
124
+ end
125
+
126
+ @server_pid = nil
127
+ puts "Server stopped"
128
+ end
129
+
130
+ # Start client processes
131
+ def start_clients
132
+ # Define the client usernames and their messages
133
+ clients = {
134
+ "alice" => [
135
+ { content: "Hello everyone!", recipient: "all" },
136
+ { content: "I'm working on a Ruby project using sockets",
137
+ recipient: "all" },
138
+ { content: "It's a simple chat server and client", recipient: "all" },
139
+ ],
140
+ "bob" => [
141
+ { content: "Hi Alice!", recipient: "alice" },
142
+ { content: "That sounds interesting. What kind of project?",
143
+ recipient: "alice" },
144
+ { content: "Cool! I love Ruby's socket features",
145
+ recipient: "alice" },
146
+ ],
147
+ "charlie" => [
148
+ { content: "How's everyone doing today?", recipient: "all" },
149
+ { content: "Are you using any specific libraries?",
150
+ recipient: "alice" },
151
+ { content: "Non-blocking IO in chat clients is efficient",
152
+ recipient: "all" },
153
+ ],
154
+ }
155
+
156
+ puts "Starting #{clients.size} clients: #{clients.keys.join(', ')}"
157
+
158
+ # Start each client in a separate process
159
+ clients.each do |username, messages|
160
+ start_client(username, messages)
161
+ end
162
+ end
163
+
164
+ # Start a single client process
165
+ def start_client(username, messages)
166
+ client_log_file = File.join(@log_dir, "client_#{username}_messages.log")
167
+ messages_file = File.join(@log_dir,
168
+ "client_#{username}_send_messages.json")
169
+
170
+ # Write the messages to a JSON file
171
+ File.write(messages_file, JSON.generate(messages))
172
+
173
+ # Get the directory where this script is located
174
+ script_dir = File.dirname(__FILE__)
175
+ client_script = File.join(script_dir, "chat_client.rb")
176
+
177
+ # Build the client command
178
+ client_cmd = "ruby #{client_script} #{username} #{@server_port} #{client_log_file}"
179
+
180
+ puts "Starting client #{username}"
181
+
182
+ # Start the client process as a fork
183
+ @client_pids[username] = fork do
184
+ exec(client_cmd)
185
+ end
186
+
187
+ puts "Client #{username} started with PID #{@client_pids[username]}"
188
+ end
189
+
190
+ # Stop all client processes
191
+ def stop_clients
192
+ return if @client_pids.empty?
193
+
194
+ puts "Stopping #{@client_pids.size} clients..."
195
+
196
+ @client_pids.each do |username, pid|
197
+ # Try to gracefully terminate the process
198
+ begin
199
+ Process.kill("INT", pid)
200
+ # Give it a moment to shut down
201
+ sleep(0.5)
202
+
203
+ # Force kill if still running
204
+ Process.kill("KILL", pid) if process_running?(pid)
205
+ rescue Errno::ESRCH
206
+ # Process already gone
207
+ end
208
+
209
+ puts "Client #{username} stopped"
210
+ rescue StandardError => e
211
+ puts "Error stopping client #{username}: #{e.message}"
212
+ end
213
+
214
+ @client_pids.clear
215
+ end
216
+
217
+ # Check if a process is still running
218
+ def process_running?(pid)
219
+ Process.getpgid(pid)
220
+ true
221
+ rescue Errno::ESRCH
222
+ false
223
+ end
224
+
225
+ # Analyze the log files after the simulation
226
+ def analyze_logs
227
+ puts "\nSimulation Results"
228
+ puts "================="
229
+
230
+ # Analyze server log
231
+ server_log_file = File.join(@log_dir, "server_messages.log")
232
+ if File.exist?(server_log_file)
233
+ server_log = File.readlines(server_log_file)
234
+ puts "Server processed #{server_log.size} log entries"
235
+
236
+ # Count message types
237
+ message_count = server_log.count do |line|
238
+ line.include?("Received from")
239
+ end
240
+ broadcast_count = server_log.count do |line|
241
+ line.include?('Broadcasting: {:type=>"broadcast"')
242
+ end
243
+ direct_count = server_log.count do |line|
244
+ line =~ /Received from \w+:.*"recipient":"(?!all)/
245
+ end
246
+
247
+ puts " - #{message_count} messages received from clients"
248
+ puts " - #{broadcast_count} broadcast messages sent"
249
+ puts " - #{direct_count} direct messages sent"
250
+ else
251
+ puts "Server log file not found"
252
+ end
253
+
254
+ puts "\nClient Activity:"
255
+ # Analyze each client log
256
+ @client_pids.each_key do |username|
257
+ client_log_file = File.join(@log_dir, "client_#{username}_messages.log")
258
+ if File.exist?(client_log_file)
259
+ client_log = File.readlines(client_log_file)
260
+ sent_count = client_log.count { |line| line.include?("Sent message") }
261
+ received_count = client_log.count do |line|
262
+ line.include?("Received:")
263
+ end
264
+
265
+ puts " #{username}: Sent #{sent_count} messages, Received #{received_count} messages"
266
+ else
267
+ puts " #{username}: Log file not found"
268
+ end
269
+ end
270
+
271
+ puts "\nLog files are available in the #{@log_dir} directory for detailed analysis."
272
+ end
273
+ end
274
+ end
275
+
276
+ # When run directly, start the simulation
277
+ if __FILE__ == $PROGRAM_NAME
278
+ options = {
279
+ port: 3000,
280
+ duration: 10,
281
+ log_dir: "logs",
282
+ }
283
+
284
+ # Parse command line options
285
+ OptionParser.new do |opts|
286
+ opts.banner = "Usage: ruby simulate.rb [options]"
287
+
288
+ opts.on("-p", "--port PORT", Integer,
289
+ "Server port (default: 3000)") do |port|
290
+ options[:port] = port
291
+ end
292
+
293
+ opts.on("-d", "--duration SECONDS", Integer,
294
+ "Simulation duration in seconds (default: 10)") do |duration|
295
+ options[:duration] = duration
296
+ end
297
+
298
+ opts.on("-l", "--log-dir DIR",
299
+ "Directory for log files (default: logs)") do |dir|
300
+ options[:log_dir] = dir
301
+ end
302
+
303
+ opts.on("-h", "--help", "Show this help message") do
304
+ puts opts
305
+ exit
306
+ end
307
+ end.parse!
308
+
309
+ puts "Starting Chat Simulation"
310
+ puts "======================"
311
+ puts "This simulation runs a chat server and multiple clients as separate processes"
312
+ puts "to demonstrate a basic chat application with socket communication."
313
+ puts
314
+
315
+ # Create and run the simulation
316
+ simulation = ContinuousChat::Simulation.new(
317
+ options[:port],
318
+ options[:duration],
319
+ options[:log_dir],
320
+ )
321
+
322
+ # Set up signal handlers to properly clean up child processes
323
+ Signal.trap("INT") do
324
+ puts "\nSimulation interrupted"
325
+ simulation.stop
326
+ exit
327
+ end
328
+
329
+ Signal.trap("TERM") do
330
+ puts "\nSimulation terminated"
331
+ simulation.stop
332
+ exit
333
+ end
334
+
335
+ begin
336
+ simulation.start
337
+ rescue Interrupt
338
+ puts "\nSimulation interrupted"
339
+ simulation.stop
340
+ end
341
+
342
+ puts "Simulation completed"
343
+ end