raptor 0.18.0 → 0.20.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.
@@ -44,7 +44,7 @@ module Rackup
44
44
  "Port=PORT" => "Port to listen on (default: #{DEFAULT_OPTIONS[:Port]})",
45
45
  "Workers=NUM" => "Number of worker processes (default: available processor count)",
46
46
  "Threads=NUM" => "Number of threads per worker (default: 3)",
47
- "MaxThreads=NUM" => "Maximum threads per worker (`unlimited` for no limit; default: fixed at Threads)",
47
+ "MaxThreads=NUM" => "Maximum threads per worker (`unlimited` for no limit; default: unlimited)",
48
48
  "Config=PATH" => "Load additional configuration from PATH"
49
49
  }
50
50
  end
@@ -71,6 +71,8 @@ module Rackup
71
71
  cli_defaults = ::Raptor::CLI::DEFAULT_OPTIONS
72
72
  config_path = options[:Config] || ::Raptor::CLI.default_config_path
73
73
  config = config_path ? ::Raptor::CLI.load_config_file(config_path) : {}
74
+ threads = (options[:Threads] || ENV["RAPTOR_THREADS"] || config[:threads] || cli_defaults[:threads]).to_i
75
+ max_threads = options[:MaxThreads] || ENV["RAPTOR_MAX_THREADS"] || config.fetch(:max_threads, cli_defaults[:max_threads])
74
76
 
75
77
  result = {
76
78
  binds: if options[:Host] || options[:Port]
@@ -80,9 +82,12 @@ module Rackup
80
82
  end,
81
83
  socket_backlog: (config[:socket_backlog] || cli_defaults[:socket_backlog]).to_i,
82
84
  drain_accept_queue: config.key?(:drain_accept_queue) ? config[:drain_accept_queue] : cli_defaults[:drain_accept_queue],
83
- workers: (options[:Workers] || config[:workers] || Concurrent.available_processor_count).to_i,
84
- threads: (options[:Threads] || config[:threads] || cli_defaults[:threads]).to_i,
85
- max_threads: ::Raptor::CLI.parse_max_threads(options[:MaxThreads] || config[:max_threads]),
85
+ workers: (options[:Workers] || ENV["RAPTOR_WORKERS"] || config[:workers] || Concurrent.available_processor_count).to_i,
86
+ threads: threads,
87
+ max_threads: ::Raptor::CLI.parse_max_threads(max_threads, threads: threads),
88
+ cpu_affinity: config.key?(:cpu_affinity) ? config[:cpu_affinity] : cli_defaults[:cpu_affinity],
89
+ clean_thread_locals: config.key?(:clean_thread_locals) ? config[:clean_thread_locals] : cli_defaults[:clean_thread_locals],
90
+ clean_fiber_locals: config.key?(:clean_fiber_locals) ? config[:clean_fiber_locals] : cli_defaults[:clean_fiber_locals],
86
91
  app: app
87
92
  }
88
93
  result[:rackup] = config[:rackup] if config.key?(:rackup)
@@ -96,6 +101,7 @@ module Rackup
96
101
  result[:worker_drain_timeout] = (config[:worker_drain_timeout] || cli_defaults[:worker_drain_timeout]).to_i
97
102
  result[:worker_shutdown_timeout] = (config[:worker_shutdown_timeout] || cli_defaults[:worker_shutdown_timeout]).to_i
98
103
  result[:stats_file] = config.key?(:stats_file) ? config[:stats_file] : cli_defaults[:stats_file]
104
+ result[:control_url] = config[:control_url] if config.key?(:control_url)
99
105
  result[:pid_file] = config[:pid_file] if config.key?(:pid_file)
100
106
  result[:stdout_file] = config[:stdout_file] if config.key?(:stdout_file)
101
107
  result[:stderr_file] = config[:stderr_file] if config.key?(:stderr_file)
data/lib/raptor/binder.rb CHANGED
@@ -106,6 +106,15 @@ module Raptor
106
106
  tcp_listener.local_address.ip_port
107
107
  end
108
108
 
109
+ # Returns whether any listener can negotiate HTTP/2.
110
+ #
111
+ # @return [Boolean]
112
+ #
113
+ # @rbs () -> bool
114
+ def http2?
115
+ @listeners.any? { |listener| listener.is_a?(SslListener) }
116
+ end
117
+
109
118
  # Closes all listening sockets.
110
119
  #
111
120
  # @return [void]
data/lib/raptor/cli.rb CHANGED
@@ -34,7 +34,10 @@ module Raptor
34
34
  drain_accept_queue: false,
35
35
  workers: DEFAULT_WORKER_COUNT,
36
36
  threads: 3,
37
- max_threads: nil,
37
+ max_threads: Float::INFINITY,
38
+ cpu_affinity: false,
39
+ clean_thread_locals: true,
40
+ clean_fiber_locals: false,
38
41
  rackup: "config.ru",
39
42
  chdir: nil,
40
43
  environment: nil,
@@ -48,7 +51,7 @@ module Raptor
48
51
  http1: {
49
52
  ractors: nil,
50
53
  persistent_data_timeout: 65,
51
- max_keepalive_requests: 100,
54
+ max_keepalive_requests: 1000,
52
55
  },
53
56
  http2: {
54
57
  ractors: nil,
@@ -64,6 +67,7 @@ module Raptor
64
67
  before_worker_shutdown: [].freeze,
65
68
  before_refork: [].freeze,
66
69
  stats_file: "tmp/raptor.json",
70
+ control_url: nil,
67
71
  pid_file: nil,
68
72
  stdout_file: nil,
69
73
  stderr_file: nil,
@@ -101,17 +105,27 @@ module Raptor
101
105
 
102
106
  # Parses a maximum thread count from a CLI, config, or Rack handler value.
103
107
  #
104
- # @param value [Integer, Float, String, nil] maximum thread count
105
- # @return [Integer, Float, nil] parsed thread count, `Float::INFINITY` for
106
- # `"unlimited"`, or nil for a fixed-size pool
108
+ # @param value [Integer, Float, String] maximum thread count
109
+ # @param threads [Integer] baseline thread count
110
+ # @return [Integer, Float] parsed thread count, or `Float::INFINITY` for
111
+ # `"unlimited"`
112
+ # @raise [ArgumentError] if the maximum is nil or less than the baseline
107
113
  #
108
- # @rbs (Integer | Float | String | nil value) -> (Integer | Float)?
109
- def self.parse_max_threads(value)
110
- return unless value
111
- return Float::INFINITY if value == "unlimited" || value == Float::INFINITY
112
- return value if value.is_a?(Integer)
114
+ # @rbs (Integer | Float | String value, threads: Integer) -> (Integer | Float)
115
+ def self.parse_max_threads(value, threads:)
116
+ raise ArgumentError, "max_threads cannot be nil" unless value
117
+
118
+ max_threads = if value == "unlimited" || value == Float::INFINITY
119
+ Float::INFINITY
120
+ elsif value.is_a?(Integer)
121
+ value
122
+ else
123
+ Integer(value, 10)
124
+ end
125
+
126
+ raise ArgumentError, "max_threads must be greater than or equal to threads" if max_threads < threads
113
127
 
114
- Integer(value, 10)
128
+ max_threads
115
129
  end
116
130
 
117
131
  # @rbs @command: Symbol
@@ -141,12 +155,13 @@ module Raptor
141
155
  end
142
156
 
143
157
  apply_config_file(extract_config_path(argv) || self.class.default_config_path)
158
+ apply_environment
144
159
 
145
160
  @parser = create_parser
146
161
  @parser.parse!(argv)
147
162
 
148
163
  @options[:rackup] = argv.first if @command == :server && argv.first
149
- @options[:max_threads] = self.class.parse_max_threads(@options[:max_threads])
164
+ @options[:max_threads] = self.class.parse_max_threads(@options[:max_threads], threads: @options[:threads])
150
165
  end
151
166
 
152
167
  # Runs the requested command.
@@ -222,6 +237,18 @@ module Raptor
222
237
  end
223
238
  end
224
239
 
240
+ # Applies worker and thread settings from the environment. Explicit CLI
241
+ # options are parsed afterwards and take precedence.
242
+ #
243
+ # @return [void]
244
+ #
245
+ # @rbs () -> void
246
+ def apply_environment
247
+ @options[:workers] = Integer(ENV["RAPTOR_WORKERS"], 10) if ENV["RAPTOR_WORKERS"]
248
+ @options[:threads] = Integer(ENV["RAPTOR_THREADS"], 10) if ENV["RAPTOR_THREADS"]
249
+ @options[:max_threads] = ENV["RAPTOR_MAX_THREADS"] if ENV["RAPTOR_MAX_THREADS"]
250
+ end
251
+
225
252
  # Creates the OptionParser instance with all supported command-line options.
226
253
  #
227
254
  # @return [OptionParser] configured option parser
@@ -259,10 +286,22 @@ module Raptor
259
286
  @options[:threads] = num
260
287
  end
261
288
 
262
- opts.on("--max-threads NUM", "Maximum application threads per worker (`unlimited` for no limit; default: fixed at --threads)") do |num|
289
+ opts.on("--max-threads NUM", "Maximum application threads per worker (`unlimited` for no limit; default: unlimited)") do |num|
263
290
  @options[:max_threads] = num
264
291
  end
265
292
 
293
+ opts.on("--[no-]cpu-affinity", "Pin each worker process to a CPU (default: off)") do |bool|
294
+ @options[:cpu_affinity] = bool
295
+ end
296
+
297
+ opts.on("--[no-]clean-thread-locals", "Clear application thread locals after each request (default: on)") do |bool|
298
+ @options[:clean_thread_locals] = bool
299
+ end
300
+
301
+ opts.on("--[no-]clean-fiber-locals", "Process each request in a fresh Fiber (default: off)") do |bool|
302
+ @options[:clean_fiber_locals] = bool
303
+ end
304
+
266
305
  opts.on("-C", "--chdir PATH", String, "Change to PATH before loading the Rack application (default: none)") do |path|
267
306
  @options[:chdir] = path
268
307
  end
@@ -299,7 +338,7 @@ module Raptor
299
338
  @options[:http1][:persistent_data_timeout] = timeout
300
339
  end
301
340
 
302
- opts.on("--http1-max-keepalive-requests NUM", Integer, "Maximum HTTP/1.1 requests per keep-alive connection (default: 100)") do |num|
341
+ opts.on("--http1-max-keepalive-requests NUM", Integer, "Maximum HTTP/1.1 requests per keep-alive connection (default: 1000)") do |num|
303
342
  @options[:http1][:max_keepalive_requests] = num
304
343
  end
305
344
 
@@ -335,6 +374,10 @@ module Raptor
335
374
  @options[:stats_file] = path
336
375
  end
337
376
 
377
+ opts.on("--control-url URI", String, "Serve cluster stats on a unix:// URI (default: off)") do |uri|
378
+ @options[:control_url] = uri
379
+ end
380
+
338
381
  opts.on("--pid-file PATH", String, "PID file path (default: none)") do |path|
339
382
  @options[:pid_file] = path
340
383
  end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "concurrent/utility/processor_counter"
5
5
  require "json"
6
+ require "time"
6
7
 
7
8
  require "atomic-ruby/atomic_thread_pool"
8
9
  require "rack/builder"
@@ -10,6 +11,7 @@ require "ractor-pool"
10
11
 
11
12
  require_relative "log"
12
13
  require_relative "binder"
14
+ require_relative "control_server"
13
15
  require_relative "reactor"
14
16
  require_relative "server"
15
17
  require_relative "reuseport_bpf"
@@ -74,6 +76,9 @@ module Raptor
74
76
  # @rbs @http2_ractor_count: Integer
75
77
  # @rbs @thread_count: Integer
76
78
  # @rbs @max_thread_count: (Integer | Float)?
79
+ # @rbs @cpu_affinity: bool
80
+ # @rbs @clean_thread_locals: bool
81
+ # @rbs @clean_fiber_locals: bool
77
82
  # @rbs @environment: String
78
83
  # @rbs @connection_options: Hash[Symbol, untyped]
79
84
  # @rbs @http1_options: Hash[Symbol, untyped]
@@ -87,6 +92,7 @@ module Raptor
87
92
  # @rbs @before_worker_shutdown: Array[Proc]
88
93
  # @rbs @before_refork: Array[Proc]
89
94
  # @rbs @stats_file: String?
95
+ # @rbs @control_server: ControlServer?
90
96
  # @rbs @pid_file: String?
91
97
  # @rbs @stdout_file: String?
92
98
  # @rbs @stderr_file: String?
@@ -102,6 +108,7 @@ module Raptor
102
108
  # @rbs @workers: Hash[Integer, Integer]
103
109
  # @rbs @timed_out: Set[Integer]
104
110
  # @rbs @stats: Stats
111
+ # @rbs @started_at: Float
105
112
  # @rbs @phase: Integer
106
113
  # @rbs @phased_restart_requested: bool
107
114
  # @rbs @phased_restarting: bool
@@ -126,7 +133,10 @@ module Raptor
126
133
  # @option options [Boolean] :drain_accept_queue whether to drain the kernel accept queue on shutdown
127
134
  # @option options [Integer] :workers number of worker processes
128
135
  # @option options [Integer] :threads number of threads per worker process
129
- # @option options [Integer, Float, nil] :max_threads maximum number of threads per worker process, `Float::INFINITY` for no limit, or nil for a fixed-size pool
136
+ # @option options [Integer, Float] :max_threads maximum number of threads per worker process, or `Float::INFINITY` for no limit
137
+ # @option options [Boolean] :cpu_affinity whether to pin each worker to a CPU
138
+ # @option options [Boolean] :clean_thread_locals whether to clear application thread locals after each request
139
+ # @option options [Boolean] :clean_fiber_locals whether to process each request in a fresh Fiber
130
140
  # @option options [#call] :app pre-built Rack application
131
141
  # @option options [String] :rackup path to Rack configuration file
132
142
  # @option options [String, nil] :chdir directory to change to before loading the Rack application, or nil to leave the working directory unchanged
@@ -140,10 +150,11 @@ module Raptor
140
150
  # @option options [Integer] :worker_shutdown_timeout seconds to wait for graceful worker exit before force-killing
141
151
  # @option options [Integer, Array<Integer>, nil] :refork_after request-count threshold(s) at which a warm worker is promoted to a fork source for phased refork; nil or 0 disables. Requires `PR_SET_CHILD_SUBREAPER` (Linux)
142
152
  # @option options [Array<Proc>] :before_fork procs called in the master before every worker fork
143
- # @option options [Array<Proc>] :before_worker_boot procs called in each worker before it begins serving
144
- # @option options [Array<Proc>] :before_worker_shutdown procs called in each worker before its graceful shutdown
153
+ # @option options [Array<Proc>] :before_worker_boot procs called with the worker index before it begins serving
154
+ # @option options [Array<Proc>] :before_worker_shutdown procs called with the worker index before its graceful shutdown
145
155
  # @option options [Array<Proc>] :before_refork procs called in a worker before it transitions to a seed
146
156
  # @option options [String, nil] :stats_file path to write per-worker stats JSON, or nil to disable
157
+ # @option options [String, nil] :control_url `unix://` URL serving cluster stats, or nil to disable
147
158
  # @option options [String, nil] :pid_file path to write the master PID to, or nil to disable
148
159
  # @option options [String, nil] :stdout_file path to redirect stdout to, reopened on SIGHUP, or nil to disable
149
160
  # @option options [String, nil] :stderr_file path to redirect stderr to, reopened on SIGHUP, or nil to disable
@@ -161,6 +172,9 @@ module Raptor
161
172
  @http2_ractor_count = options[:http2][:ractors] || self.class.default_http2_ractor_count(@worker_count)
162
173
  @thread_count = options[:threads]
163
174
  @max_thread_count = options[:max_threads]
175
+ @cpu_affinity = options[:cpu_affinity]
176
+ @clean_thread_locals = options[:clean_thread_locals]
177
+ @clean_fiber_locals = options[:clean_fiber_locals]
164
178
  @environment = options[:environment] || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
165
179
  @connection_options = options[:connection]
166
180
  @http1_options = options[:http1]
@@ -174,6 +188,7 @@ module Raptor
174
188
  @before_worker_shutdown = Array(options[:before_worker_shutdown])
175
189
  @before_refork = Array(options[:before_refork])
176
190
  @stats_file = options[:stats_file]
191
+ @control_server = ControlServer.new(options[:control_url]) { stats_hash } if options[:control_url]
177
192
  @pid_file = options[:pid_file]
178
193
  @stdout_file = options[:stdout_file]
179
194
  @stderr_file = options[:stderr_file]
@@ -202,6 +217,7 @@ module Raptor
202
217
  @workers = {}
203
218
  @timed_out = Set.new
204
219
  @stats = Stats.new(@worker_count)
220
+ @started_at = Process.clock_gettime(Process::CLOCK_REALTIME)
205
221
  @phase = 0
206
222
  @phased_restart_requested = false
207
223
  @phased_restarting = false
@@ -247,7 +263,9 @@ module Raptor
247
263
 
248
264
  @bpf_active = ReuseportBPF.setup(@worker_count)
249
265
 
266
+ @control_server&.bind
250
267
  @worker_count.times { |index| spawn_worker(index) }
268
+ @control_server&.start
251
269
 
252
270
  stats_file_thread = if @stats_file
253
271
  Thread.new do
@@ -274,6 +292,7 @@ module Raptor
274
292
  Systemd.notify("STOPPING=1")
275
293
  stop_workers
276
294
  stats_file_thread&.join
295
+ @control_server&.shutdown
277
296
  File.delete(@stats_file) rescue nil if @stats_file
278
297
  File.delete(@pid_file) rescue nil if @pid_file
279
298
  @stats.unmap
@@ -291,8 +310,57 @@ module Raptor
291
310
  @stats.all
292
311
  end
293
312
 
313
+ # Returns cluster statistics for the control server.
314
+ # For an adaptive pool, `max_threads` is the number of threads currently
315
+ # running so utilization reflects the capacity available at that moment.
316
+ #
317
+ # @return [Hash] cluster statistics
318
+ #
319
+ # @rbs () -> Hash[Symbol, untyped]
320
+ def stats_hash
321
+ worker_stats = @stats.all
322
+ worker_status = @workers.sort.map do |index, pid|
323
+ stat = worker_stats[index]
324
+ stat = {} unless stat && stat[:pid] == pid
325
+ capacity = stat.fetch(:thread_capacity, 0)
326
+ active = stat.fetch(:busy_threads, 0)
327
+ total_work = stat.fetch(:backlog, 0)
328
+
329
+ {
330
+ started_at: timestamp(stat.fetch(:started_at, 0)),
331
+ pid: pid,
332
+ index: index,
333
+ phase: stat.fetch(:phase, @phase),
334
+ booted: stat.fetch(:booted, false),
335
+ last_checkin: timestamp(stat.fetch(:last_checkin, 0)),
336
+ last_status: {
337
+ backlog: [total_work - active, 0].max,
338
+ running: capacity,
339
+ pool_capacity: [capacity - total_work, 0].max,
340
+ busy_threads: active,
341
+ max_threads: capacity,
342
+ requests_count: stat.fetch(:requests, 0),
343
+ },
344
+ }
345
+ end
346
+
347
+ {
348
+ started_at: timestamp(@started_at),
349
+ workers: worker_status.length,
350
+ phase: @phase,
351
+ booted_workers: worker_status.count { |worker| worker[:booted] },
352
+ old_workers: worker_status.count { |worker| worker[:phase] != @phase },
353
+ worker_status: worker_status,
354
+ }
355
+ end
356
+
294
357
  private
295
358
 
359
+ # @rbs (Float timestamp) -> String
360
+ def timestamp(timestamp)
361
+ Time.at(timestamp).utc.iso8601(6)
362
+ end
363
+
296
364
  # Returns the inherited-FDs hash for a systemd socket-activation handoff,
297
365
  # pairing each bind URI with the FD systemd passed at the same index.
298
366
  # Returns an empty hash when the FD count doesn't match the bind count.
@@ -700,6 +768,7 @@ module Raptor
700
768
  stop_workers
701
769
  @binder.clear_close_on_exec
702
770
  ENV[INHERITED_FDS_ENV] = JSON.generate(@binder.inheritable_fds)
771
+ @control_server&.shutdown
703
772
  File.delete(@stats_file) rescue nil if @stats_file
704
773
  File.delete(@pid_file) rescue nil if @pid_file
705
774
  @stats.unmap
@@ -731,7 +800,7 @@ module Raptor
731
800
  trap("USR2", "IGNORE")
732
801
  trap("URG") { promote_to_seed = true } if @fork_r
733
802
 
734
- Raptor::CPU.pin(index) if Raptor::CPU.count >= @worker_count
803
+ Raptor::CPU.pin(index) if @cpu_affinity && Raptor::CPU.count >= @worker_count
735
804
 
736
805
  started_at = Process.clock_gettime(Process::CLOCK_REALTIME)
737
806
  request_count = 0
@@ -762,6 +831,8 @@ module Raptor
762
831
  connection_options: @connection_options,
763
832
  http1_options: @http1_options,
764
833
  access_log_io: @access_log_io,
834
+ clean_thread_locals: @clean_thread_locals,
835
+ clean_fiber_locals: @clean_fiber_locals,
765
836
  on_error: @on_error
766
837
  )
767
838
  http2 = Http2.new(
@@ -770,6 +841,8 @@ module Raptor
770
841
  connection_options: @connection_options,
771
842
  http2_options: @http2_options,
772
843
  access_log_io: @access_log_io,
844
+ clean_thread_locals: @clean_thread_locals,
845
+ clean_fiber_locals: @clean_fiber_locals,
773
846
  on_error: @on_error
774
847
  )
775
848
  http1_ractor_pool = RactorPool.new(
@@ -784,15 +857,17 @@ module Raptor
784
857
  end
785
858
  end
786
859
 
787
- http2_ractor_pool = RactorPool.new(
788
- size: @http2_ractor_count,
789
- worker: http2.parser_worker,
790
- name: "HTTP/2"
791
- ) do |parsed_result|
792
- begin
793
- http2.handle_parsed_request(parsed_result, reactor, thread_pool)
794
- rescue => error
795
- Log.rescued_error(error)
860
+ http2_ractor_pool = if @binder.http2?
861
+ RactorPool.new(
862
+ size: @http2_ractor_count,
863
+ worker: http2.parser_worker,
864
+ name: "HTTP/2"
865
+ ) do |parsed_result|
866
+ begin
867
+ http2.handle_parsed_request(parsed_result, reactor, thread_pool)
868
+ rescue => error
869
+ Log.rescued_error(error)
870
+ end
796
871
  end
797
872
  end
798
873
 
@@ -827,7 +902,7 @@ module Raptor
827
902
  )
828
903
  server_thread = server.run
829
904
 
830
- @before_worker_boot.each(&:call)
905
+ @before_worker_boot.each { |hook| hook.parameters.empty? ? hook.call : hook.call(index) }
831
906
 
832
907
  Log.info "Worker #{index} booted"
833
908
 
@@ -863,7 +938,7 @@ module Raptor
863
938
  @before_refork.each(&:call)
864
939
  server.stop_accepting
865
940
  else
866
- @before_worker_shutdown.each(&:call)
941
+ @before_worker_shutdown.each { |hook| hook.parameters.empty? ? hook.call : hook.call(index) }
867
942
  server.shutdown
868
943
  end
869
944
 
@@ -871,7 +946,7 @@ module Raptor
871
946
  reactor.shutdown
872
947
  reactor_thread.join
873
948
  http1_ractor_pool.shutdown
874
- http2_ractor_pool.shutdown
949
+ http2_ractor_pool&.shutdown
875
950
  http1.shutdown
876
951
  drain_thread_pool(thread_pool)
877
952
  stats_thread.join
@@ -979,8 +1054,10 @@ module Raptor
979
1054
  Log.info "│ ├─ 1 server thread"
980
1055
  Log.info "│ ├─ 1 reactor thread"
981
1056
  Log.info "│ ├─ #{@http1_ractor_count} HTTP/1.1 pipeline ractor#{"s" if @http1_ractor_count > 1}"
982
- Log.info "│ ├─ #{@http2_ractor_count} HTTP/2 pipeline ractor#{"s" if @http2_ractor_count > 1}"
983
- Log.info "│ ├─ 2 pipeline collector threads"
1057
+ http2_enabled = @binder.http2?
1058
+ Log.info "│ ├─ #{@http2_ractor_count} HTTP/2 pipeline ractor#{"s" if @http2_ractor_count > 1}" if http2_enabled
1059
+ collector_count = http2_enabled ? 2 : 1
1060
+ Log.info "│ ├─ #{collector_count} pipeline collector thread#{"s" if collector_count > 1}"
984
1061
  thread_limit = if @max_thread_count == Float::INFINITY
985
1062
  " (scaling, no limit)"
986
1063
  elsif @max_thread_count
@@ -0,0 +1,164 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "socket"
6
+ require "uri"
7
+
8
+ require "atomic-ruby/atom"
9
+ require "atomic-ruby/atomic_boolean"
10
+
11
+ module Raptor
12
+ # Serves cluster statistics over a Unix socket.
13
+ #
14
+ class ControlServer
15
+ MAX_REQUEST_SIZE = 16 * 1024
16
+ REQUEST_TIMEOUT = 1
17
+
18
+ # @rbs @path: String
19
+ # @rbs @stats: ^() -> Hash[Symbol, untyped]
20
+ # @rbs @server: UNIXServer?
21
+ # @rbs @client: Atom
22
+ # @rbs @thread: Thread?
23
+ # @rbs @running: AtomicBoolean
24
+
25
+ # Creates a control server for `url` without binding it.
26
+ #
27
+ # @param url [String] `unix://` URL to listen on
28
+ # @yieldreturn [Hash] cluster statistics
29
+ # @return [void]
30
+ # @raise [ArgumentError] if the URL is not a Unix socket
31
+ #
32
+ # @rbs (String url) { () -> Hash[Symbol, untyped] } -> void
33
+ def initialize(url, &stats)
34
+ uri = URI(url)
35
+ raise ArgumentError, "control_url must use unix://" unless uri.scheme == "unix" && !uri.path.empty?
36
+
37
+ @path = uri.path
38
+ @stats = stats
39
+ @server = nil
40
+ @client = Atom.new(nil)
41
+ @thread = nil
42
+ @running = AtomicBoolean.new(false)
43
+ end
44
+
45
+ # Binds the Unix socket.
46
+ #
47
+ # @return [void]
48
+ #
49
+ # @rbs () -> void
50
+ def bind
51
+ remove_stale_socket
52
+ @server = UNIXServer.new(@path)
53
+ end
54
+
55
+ # Starts serving requests in a background thread.
56
+ #
57
+ # @return [void]
58
+ #
59
+ # @rbs () -> void
60
+ def start
61
+ @running.make_true
62
+ owner_pid = Process.pid
63
+ at_exit { File.delete(@path) rescue nil if Process.pid == owner_pid }
64
+
65
+ @thread = Thread.new do
66
+ Thread.current.name = "Control Server"
67
+
68
+ serve
69
+ end
70
+ end
71
+
72
+ # Stops serving and removes the socket.
73
+ #
74
+ # @return [void]
75
+ #
76
+ # @rbs () -> void
77
+ def shutdown
78
+ @running.make_false
79
+ @server&.close
80
+ close_client
81
+ @thread&.join
82
+ File.delete(@path) rescue nil
83
+ end
84
+
85
+ private
86
+
87
+ # @rbs () -> void
88
+ def remove_stale_socket
89
+ return unless File.exist?(@path)
90
+
91
+ begin
92
+ UNIXSocket.new(@path).close
93
+ raise "Socket #{@path.inspect} is already in use"
94
+ rescue Errno::ECONNREFUSED
95
+ File.delete(@path)
96
+ end
97
+ end
98
+
99
+ # @rbs () -> void
100
+ def serve
101
+ while @running.true?
102
+ readable, = IO.select([@server], nil, nil, 1)
103
+ next unless readable
104
+
105
+ client = @server.accept_nonblock(exception: false)
106
+ next unless client.is_a?(UNIXSocket)
107
+
108
+ @client.swap { client }
109
+ unless @running.true?
110
+ close_client
111
+ break
112
+ end
113
+
114
+ handle(client)
115
+ end
116
+ rescue IOError, Errno::EBADF
117
+ end
118
+
119
+ # @rbs (UNIXSocket client) -> void
120
+ def handle(client)
121
+ request = read_request(client)
122
+ return unless request
123
+
124
+ if request.start_with?("GET /stats ")
125
+ body = JSON.generate(@stats.call)
126
+ client.write("HTTP/1.0 200 OK\r\nContent-Type: application/json\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
127
+ else
128
+ client.write("HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n")
129
+ end
130
+ rescue IOError, SystemCallError
131
+ ensure
132
+ close_client
133
+ end
134
+
135
+ # @rbs (UNIXSocket client) -> String?
136
+ def read_request(client)
137
+ request = String.new
138
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REQUEST_TIMEOUT
139
+
140
+ loop do
141
+ return request if request.include?("\r\n\r\n")
142
+ return if request.bytesize >= MAX_REQUEST_SIZE
143
+
144
+ timeout = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
145
+ return if timeout <= 0 || !client.wait_readable(timeout)
146
+
147
+ chunk = client.read_nonblock(MAX_REQUEST_SIZE - request.bytesize, exception: false)
148
+ return unless chunk.is_a?(String)
149
+
150
+ request << chunk
151
+ end
152
+ end
153
+
154
+ # @rbs () -> void
155
+ def close_client
156
+ client = nil
157
+ @client.swap do |current|
158
+ client = current
159
+ nil
160
+ end
161
+ client&.close
162
+ end
163
+ end
164
+ end