raptor 0.1.0

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.
@@ -0,0 +1,357 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ require "atomic-ruby/atomic_thread_pool"
7
+ require "rack/builder"
8
+ require "ractor-pool"
9
+
10
+ require_relative "binder"
11
+ require_relative "server"
12
+ require_relative "reactor"
13
+ require_relative "request"
14
+ require_relative "http2"
15
+ require_relative "stats"
16
+
17
+ module Raptor
18
+ # Multi-process web server cluster with advanced concurrency architecture.
19
+ #
20
+ # Cluster manages multiple worker processes, each running a complete server
21
+ # stack including a reactor thread, server thread, ractor pool for HTTP
22
+ # parsing, and thread pool for application processing. It handles process
23
+ # forking, signal management, graceful shutdown, and automatic worker
24
+ # restart when a worker process unexpectedly exits.
25
+ #
26
+ # The architecture provides horizontal scaling through processes while
27
+ # maintaining efficient I/O and CPU utilization within each process through
28
+ # the combination of NIO reactors, ractor-based parsing, and thread pools.
29
+ #
30
+ # Flow per worker process:
31
+ # 1. Server continuously accepts connections but skips acceptance when backlog is high
32
+ # 2. Reactor manages I/O multiplexing and provides backlog metrics for load control
33
+ # 3. Ractor pool handles CPU-intensive HTTP parsing in parallel
34
+ # 4. Thread pool processes Rack applications and handles response writing
35
+ # 5. Natural load balancing occurs through backpressure-based acceptance control
36
+ #
37
+ # @example Basic usage
38
+ # options = {
39
+ # threads: 8, ractors: 2, workers: 4,
40
+ # binds: ["tcp://0.0.0.0:3000"],
41
+ # rackup: "config.ru",
42
+ # client: { first_data_timeout: 30, chunk_data_timeout: 10 }
43
+ # }
44
+ # Cluster.run(options)
45
+ #
46
+ class Cluster
47
+ # Convenience method to create and run a cluster with the given options.
48
+ #
49
+ # @param options [Hash] cluster configuration options
50
+ # @return [void]
51
+ #
52
+ # @rbs (Hash[Symbol, untyped] options) -> void
53
+ def self.run(options)
54
+ new(options).run
55
+ end
56
+
57
+ # @rbs @thread_count: Integer
58
+ # @rbs @ractor_count: Integer
59
+ # @rbs @worker_count: Integer
60
+ # @rbs @client_options: Hash[Symbol, Integer]
61
+ # @rbs @binder: Binder
62
+ # @rbs @server_port: Integer
63
+ # @rbs @app: untyped
64
+ # @rbs @shutdown: bool
65
+ # @rbs @workers: Hash[Integer, Integer]
66
+ # @rbs @stats: Stats
67
+ # @rbs @stats_file: String?
68
+
69
+ # Creates a new Cluster with the specified configuration.
70
+ #
71
+ # Initializes the cluster with thread, ractor, and worker counts,
72
+ # sets up network binding, loads the Rack application, and prepares
73
+ # for multi-process operation.
74
+ #
75
+ # @param options [Hash] cluster configuration options
76
+ # @option options [Integer] :threads number of threads per worker process
77
+ # @option options [Integer] :ractors number of ractors per worker process
78
+ # @option options [Integer] :workers number of worker processes
79
+ # @option options [Array<String>] :binds array of bind URIs
80
+ # @option options [String] :rackup path to Rack configuration file
81
+ # @option options [Hash] :client client timeout configuration
82
+ # @return [void]
83
+ #
84
+ # @rbs (Hash[Symbol, untyped] options) -> void
85
+ def initialize(options)
86
+ @thread_count = options[:threads]
87
+ @ractor_count = options[:ractors]
88
+ @worker_count = options[:workers]
89
+ @client_options = options[:client]
90
+
91
+ @binder = Binder.new(options[:binds])
92
+ @server_port = @binder.server_port
93
+ @app = Rack::Builder.parse_file(options[:rackup])
94
+ log_initialization
95
+
96
+ @shutdown = false
97
+ @workers = {}
98
+ @stats = Stats.new(@worker_count)
99
+ @stats_file = options[:stats_file]
100
+ end
101
+
102
+ # Starts the multi-process cluster and manages worker processes.
103
+ #
104
+ # Forks the configured number of worker processes and monitors them,
105
+ # automatically restarting any that exit unexpectedly. Handles graceful
106
+ # shutdown via INT or TERM signals, and stats logging via USR1.
107
+ #
108
+ # Each worker process includes:
109
+ # - 1 server thread (continuously accepts connections with backpressure control)
110
+ # - 1 reactor thread (I/O multiplexing, timeout handling, backlog monitoring)
111
+ # - N ractor workers (parallel HTTP parsing)
112
+ # - 1 ractor collector thread (coordinates parsing results)
113
+ # - M worker threads (Rack application processing and response writing)
114
+ # - 1 stats thread (writes per-worker metrics to shared memory every second)
115
+ #
116
+ # @return [void]
117
+ #
118
+ # @rbs () -> void
119
+ def run
120
+ trap("INT") { shutdown }
121
+ trap("TERM") { shutdown }
122
+ trap("USR1") { log_stats }
123
+
124
+ @worker_count.times { |index| spawn_worker(index) }
125
+
126
+ stats_file_thread = if @stats_file
127
+ Thread.new do
128
+ Thread.current.name = "Raptor Stats File"
129
+
130
+ write_stats_file_loop
131
+ end
132
+ end
133
+
134
+ until @shutdown
135
+ begin
136
+ pid, status = Process.wait2(-1, Process::WNOHANG)
137
+ rescue Errno::ECHILD
138
+ break
139
+ end
140
+
141
+ if pid
142
+ index = @workers.key(pid)
143
+ @workers.delete(index)
144
+
145
+ unless @shutdown
146
+ warn "[#{Process.pid}] Restarting worker #{index} (#{pid}), #{exit_description(status)}"
147
+ spawn_worker(index)
148
+ end
149
+ else
150
+ sleep 0.1
151
+ end
152
+ end
153
+
154
+ @workers.values.each { |pid| Process.kill("TERM", pid) rescue nil }
155
+ @workers.values.each { |pid| Process.wait(pid) rescue nil }
156
+ stats_file_thread&.join
157
+ File.delete(@stats_file) rescue nil if @stats_file
158
+ @stats.unmap
159
+ end
160
+
161
+ # Returns stats for all worker processes.
162
+ #
163
+ # @return [Array<Hash>] array of per-worker stat hashes, each containing
164
+ # :pid, :requests, :backlog, :started_at, :last_checkin, and :booted
165
+ #
166
+ # @rbs () -> Array[Hash[Symbol, untyped]]
167
+ def stats
168
+ @stats.all
169
+ end
170
+
171
+ private
172
+
173
+ # Forks a new worker process and registers it at the given index.
174
+ #
175
+ # @param index [Integer] slot index for this worker in the stats region
176
+ # @return [void]
177
+ #
178
+ # @rbs (Integer index) -> void
179
+ def spawn_worker(index)
180
+ pid = fork { run_worker(index) }
181
+ @workers[index] = pid
182
+ end
183
+
184
+ # Runs the full server stack inside a worker process.
185
+ #
186
+ # Sets up and coordinates the reactor, server, ractor pool, thread pool,
187
+ # and stats thread, running until a shutdown signal is received or a
188
+ # critical component fails.
189
+ #
190
+ # @param index [Integer] slot index for this worker in the stats region
191
+ # @return [void]
192
+ #
193
+ # @rbs (Integer index) -> void
194
+ def run_worker(index)
195
+ shutdown_requested = false
196
+ trap("INT") { shutdown_requested = true }
197
+ trap("TERM") { shutdown_requested = true }
198
+
199
+ started_at = Process.clock_gettime(Process::CLOCK_REALTIME)
200
+ request_count = 0
201
+
202
+ @stats.write(
203
+ index,
204
+ pid: Process.pid,
205
+ requests: 0,
206
+ backlog: 0,
207
+ started_at:,
208
+ last_checkin: started_at,
209
+ booted: false
210
+ )
211
+
212
+ reactor = nil
213
+
214
+ counting_app = ->(env) {
215
+ request_count += 1
216
+ @app.call(env)
217
+ }
218
+ thread_pool = AtomicThreadPool.new(name: "Raptor Workers", size: @thread_count)
219
+ request = Request.new(counting_app, @server_port)
220
+ http2 = Http2.new(counting_app, @server_port)
221
+ ractor_pool = RactorPool.new(
222
+ name: "Raptor Pipeline Workers",
223
+ size: @ractor_count,
224
+ worker: request.http_parser_worker
225
+ ) do |parsed_result|
226
+ if parsed_result[:protocol] == :http2
227
+ http2.handle_parsed_request(parsed_result, reactor, thread_pool)
228
+ else
229
+ request.handle_parsed_request(parsed_result, reactor, thread_pool)
230
+ end
231
+ end
232
+
233
+ reactor = Reactor.new(thread_pool, ractor_pool, client_options: @client_options)
234
+ reactor_thread = reactor.run
235
+
236
+ server = Server.new(@binder, reactor, thread_pool)
237
+ server_thread = server.run
238
+
239
+ puts "[#{Process.pid}] Worker #{index} booted"
240
+
241
+ stats_thread = Thread.new do
242
+ Thread.current.name = "Raptor Stats"
243
+
244
+ loop do
245
+ @stats.write(
246
+ index,
247
+ pid: Process.pid,
248
+ requests: request_count,
249
+ backlog: reactor.backlog,
250
+ started_at:,
251
+ last_checkin: Process.clock_gettime(Process::CLOCK_REALTIME),
252
+ booted: true
253
+ )
254
+ break if shutdown_requested
255
+
256
+ sleep 1
257
+ end
258
+ end
259
+
260
+ until shutdown_requested
261
+ break unless server_thread.alive? && reactor_thread.alive?
262
+
263
+ sleep 0.5
264
+ end
265
+
266
+ server.shutdown
267
+ server_thread.join
268
+ reactor.shutdown
269
+ reactor_thread.join
270
+ ractor_pool.shutdown
271
+ thread_pool.shutdown
272
+ stats_thread.join
273
+ end
274
+
275
+ # Returns a human-readable description of how a process exited.
276
+ #
277
+ # @param status [Process::Status] the exit status of the process
278
+ # @return [String] a description of the exit reason
279
+ #
280
+ # @rbs (Process::Status status) -> String
281
+ def exit_description(status)
282
+ if status.exited?
283
+ "exited with code #{status.exitstatus}"
284
+ elsif status.signaled?
285
+ "killed by SIG#{Signal.signame(status.termsig)}"
286
+ else
287
+ "exited"
288
+ end
289
+ end
290
+
291
+ # Initiates graceful shutdown of the cluster.
292
+ #
293
+ # @return [void]
294
+ #
295
+ # @rbs () -> void
296
+ def shutdown
297
+ return if @shutdown
298
+
299
+ @shutdown = true
300
+ end
301
+
302
+ # Logs cluster initialization details including architecture and bind addresses.
303
+ #
304
+ # Outputs a hierarchical view of the cluster configuration showing
305
+ # the master process, worker processes, and per-process thread/ractor
306
+ # allocation along with listening addresses.
307
+ #
308
+ # @return [void]
309
+ #
310
+ # @rbs () -> void
311
+ def log_initialization
312
+ puts "Raptor Cluster initializing:"
313
+ puts "├─ Version: #{VERSION}"
314
+ puts "├─ Ruby Version: #{RUBY_DESCRIPTION}"
315
+ puts "├─ Master PID: #{Process.pid}"
316
+ puts "│ └─ #{@worker_count} worker process#{"es" if @worker_count > 1}"
317
+ puts "│ ├─ 1 server thread"
318
+ puts "│ ├─ 1 reactor thread"
319
+ puts "│ ├─ #{@ractor_count} pipeline ractor#{"s" if @ractor_count > 1}"
320
+ puts "│ ├─ 1 pipeline collector thread"
321
+ puts "│ ├─ #{@thread_count} worker thread#{"s" if @thread_count > 1}"
322
+ puts "│ └─ 1 stats thread"
323
+ puts "└─ Listening on #{@binder.addresses.join(", ")}"
324
+ end
325
+
326
+ # Logs current stats for all workers to stdout.
327
+ #
328
+ # Triggered by SIGUSR1 in the master process.
329
+ #
330
+ # @return [void]
331
+ #
332
+ # @rbs () -> void
333
+ def log_stats
334
+ @stats.all.each_with_index do |stat, index|
335
+ status = stat[:booted] ? "booted" : "starting"
336
+ puts "Worker #{index}: pid=#{stat[:pid]}, requests=#{stat[:requests]}, " \
337
+ "backlog=#{stat[:backlog]}, #{status}, " \
338
+ "last_checkin=#{Time.at(stat[:last_checkin]).strftime("%H:%M:%S")}"
339
+ end
340
+ end
341
+
342
+ # Writes the stats file on a 1-second interval until shutdown.
343
+ #
344
+ # @return [void]
345
+ #
346
+ # @rbs () -> void
347
+ def write_stats_file_loop
348
+ loop do
349
+ File.write(@stats_file, JSON.generate({ master_pid: Process.pid, workers: @stats.all }))
350
+ break if @shutdown
351
+
352
+ sleep 1
353
+ end
354
+ rescue SystemCallError
355
+ end
356
+ end
357
+ end