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.
- checksums.yaml +7 -0
- data/.buildkite/pipeline.yml +36 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +86 -0
- data/Rakefile +28 -0
- data/exe/raptor +8 -0
- data/ext/raptor_http/extconf.rb +7 -0
- data/ext/raptor_http/raptor_http.c +1248 -0
- data/ext/raptor_http2/extconf.rb +7 -0
- data/ext/raptor_http2/huffman_table.h +4888 -0
- data/ext/raptor_http2/raptor_http2.c +772 -0
- data/lib/raptor/binder.rb +249 -0
- data/lib/raptor/cli.rb +171 -0
- data/lib/raptor/cluster.rb +357 -0
- data/lib/raptor/http2.rb +416 -0
- data/lib/raptor/reactor.rb +411 -0
- data/lib/raptor/request.rb +992 -0
- data/lib/raptor/server.rb +167 -0
- data/lib/raptor/stats.rb +94 -0
- data/lib/raptor/version.rb +6 -0
- data/lib/raptor.rb +13 -0
- data/sig/generated/raptor/binder.rbs +162 -0
- data/sig/generated/raptor/cli.rbs +71 -0
- data/sig/generated/raptor/cluster.rbs +171 -0
- data/sig/generated/raptor/http2.rbs +145 -0
- data/sig/generated/raptor/reactor.rbs +251 -0
- data/sig/generated/raptor/request.rbs +477 -0
- data/sig/generated/raptor/server.rbs +88 -0
- data/sig/generated/raptor/stats.rbs +78 -0
- data/sig/generated/raptor/version.rbs +5 -0
- data/sig/generated/raptor.rbs +9 -0
- metadata +160 -0
|
@@ -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
|