puma 6.6.1 → 7.2.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +224 -4
  3. data/README.md +34 -34
  4. data/docs/deployment.md +58 -23
  5. data/docs/fork_worker.md +5 -5
  6. data/docs/jungle/README.md +1 -1
  7. data/docs/kubernetes.md +11 -16
  8. data/docs/plugins.md +2 -2
  9. data/docs/restart.md +2 -2
  10. data/docs/signals.md +19 -19
  11. data/docs/stats.md +4 -3
  12. data/docs/systemd.md +3 -3
  13. data/ext/puma_http11/extconf.rb +2 -17
  14. data/ext/puma_http11/mini_ssl.c +18 -8
  15. data/ext/puma_http11/org/jruby/puma/Http11.java +9 -1
  16. data/ext/puma_http11/puma_http11.c +122 -118
  17. data/lib/puma/app/status.rb +10 -2
  18. data/lib/puma/binder.rb +10 -8
  19. data/lib/puma/cli.rb +3 -5
  20. data/lib/puma/client.rb +52 -56
  21. data/lib/puma/cluster/worker.rb +17 -17
  22. data/lib/puma/cluster/worker_handle.rb +38 -7
  23. data/lib/puma/cluster.rb +23 -23
  24. data/lib/puma/cluster_accept_loop_delay.rb +91 -0
  25. data/lib/puma/commonlogger.rb +3 -3
  26. data/lib/puma/configuration.rb +104 -51
  27. data/lib/puma/const.rb +9 -10
  28. data/lib/puma/control_cli.rb +6 -2
  29. data/lib/puma/detect.rb +2 -0
  30. data/lib/puma/dsl.rb +149 -91
  31. data/lib/puma/error_logger.rb +3 -1
  32. data/lib/puma/events.rb +25 -10
  33. data/lib/puma/io_buffer.rb +8 -4
  34. data/lib/puma/launcher/bundle_pruner.rb +1 -1
  35. data/lib/puma/launcher.rb +54 -49
  36. data/lib/puma/minissl.rb +0 -1
  37. data/lib/puma/plugin/systemd.rb +3 -3
  38. data/lib/puma/rack/urlmap.rb +1 -1
  39. data/lib/puma/reactor.rb +19 -13
  40. data/lib/puma/request.rb +42 -31
  41. data/lib/puma/runner.rb +9 -18
  42. data/lib/puma/server.rb +114 -64
  43. data/lib/puma/single.rb +6 -3
  44. data/lib/puma/state_file.rb +3 -2
  45. data/lib/puma/thread_pool.rb +47 -82
  46. data/lib/puma/util.rb +0 -7
  47. data/lib/puma.rb +10 -0
  48. data/lib/rack/handler/puma.rb +2 -2
  49. data/tools/Dockerfile +13 -5
  50. metadata +6 -5
  51. data/ext/puma_http11/ext_help.h +0 -15
data/lib/puma/client.rb CHANGED
@@ -1,13 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class IO
4
- # We need to use this for a jruby work around on both 1.8 and 1.9.
5
- # So this either creates the constant (on 1.8), or harmlessly
6
- # reopens it (on 1.9).
7
- module WaitReadable
8
- end
9
- end
10
-
11
3
  require_relative 'detect'
12
4
  require_relative 'io_buffer'
13
5
  require 'tempfile'
@@ -64,6 +56,11 @@ module Puma
64
56
 
65
57
  TE_ERR_MSG = 'Invalid Transfer-Encoding'
66
58
 
59
+ # See:
60
+ # https://httpwg.org/specs/rfc9110.html#rfc.section.5.6.1.1
61
+ # https://httpwg.org/specs/rfc9112.html#rfc.section.6.1
62
+ STRIP_OWS = /\A[ \t]+|[ \t]+\z/
63
+
67
64
  # The object used for a request with no body. All requests with
68
65
  # no body share this one object since it has no state.
69
66
  EmptyBody = NullIO.new
@@ -111,7 +108,8 @@ module Puma
111
108
  end
112
109
 
113
110
  attr_reader :env, :to_io, :body, :io, :timeout_at, :ready, :hijacked,
114
- :tempfile, :io_buffer, :http_content_length_limit_exceeded
111
+ :tempfile, :io_buffer, :http_content_length_limit_exceeded,
112
+ :requests_served
115
113
 
116
114
  attr_writer :peerip, :http_content_length_limit
117
115
 
@@ -133,9 +131,9 @@ module Puma
133
131
  "#<Puma::Client:0x#{object_id.to_s(16)} @ready=#{@ready.inspect}>"
134
132
  end
135
133
 
136
- # For the hijack protocol (allows us to just put the Client object
137
- # into the env)
138
- def call
134
+ # For the full hijack protocol, `env['rack.hijack']` is set to
135
+ # `client.method :full_hijack`
136
+ def full_hijack
139
137
  @hijacked = true
140
138
  env[HIJACK_IO] ||= @io
141
139
  end
@@ -150,11 +148,12 @@ module Puma
150
148
  end
151
149
 
152
150
  # Number of seconds until the timeout elapses.
151
+ # @!attribute [r] timeout
153
152
  def timeout
154
153
  [@timeout_at - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
155
154
  end
156
155
 
157
- def reset(fast_check=true)
156
+ def reset
158
157
  @parser.reset
159
158
  @io_buffer.reset
160
159
  @read_header = true
@@ -166,7 +165,10 @@ module Puma
166
165
  @peerip = nil if @remote_addr_header
167
166
  @in_last_chunk = false
168
167
  @http_content_length_limit_exceeded = false
168
+ end
169
169
 
170
+ # only used with back-to-back requests contained in the buffer
171
+ def process_back_to_back_requests
170
172
  if @buffer
171
173
  return false unless try_to_parse_proxy_protocol
172
174
 
@@ -178,25 +180,20 @@ module Puma
178
180
  raise HttpParserError,
179
181
  "HEADER is longer than allowed, aborting client early."
180
182
  end
181
-
182
- return false
183
- else
184
- begin
185
- if fast_check && @to_io.wait_readable(FAST_TRACK_KA_TIMEOUT)
186
- return try_to_finish
187
- end
188
- rescue IOError
189
- # swallow it
190
- end
191
183
  end
192
184
  end
193
185
 
186
+ # if a client sends back-to-back requests, the buffer may contain one or more
187
+ # of them.
188
+ def has_back_to_back_requests?
189
+ !(@buffer.nil? || @buffer.empty?)
190
+ end
191
+
194
192
  def close
195
193
  tempfile_close
196
194
  begin
197
195
  @io.close
198
196
  rescue IOError, Errno::EBADF
199
- Puma::Util.purge_interrupt_queue
200
197
  end
201
198
  end
202
199
 
@@ -291,8 +288,10 @@ module Puma
291
288
 
292
289
  def eagerly_finish
293
290
  return true if @ready
294
- return false unless @to_io.wait_readable(0)
295
- try_to_finish
291
+ while @to_io.wait_readable(0) # rubocop: disable Style/WhileUntilModifier
292
+ return true if try_to_finish
293
+ end
294
+ false
296
295
  end
297
296
 
298
297
  def finish(timeout)
@@ -412,17 +411,18 @@ module Puma
412
411
  if te
413
412
  te_lwr = te.downcase
414
413
  if te.include? ','
415
- te_ary = te_lwr.split ','
414
+ te_ary = te_lwr.split(',').each { |te| te.gsub!(STRIP_OWS, "") }
416
415
  te_count = te_ary.count CHUNKED
417
416
  te_valid = te_ary[0..-2].all? { |e| ALLOWED_TRANSFER_ENCODING.include? e }
418
- if te_ary.last == CHUNKED && te_count == 1 && te_valid
419
- @env.delete TRANSFER_ENCODING2
420
- return setup_chunked_body body
421
- elsif te_count >= 1
417
+ if te_count > 1
422
418
  raise HttpParserError , "#{TE_ERR_MSG}, multiple chunked: '#{te}'"
419
+ elsif te_ary.last != CHUNKED
420
+ raise HttpParserError , "#{TE_ERR_MSG}, last value must be chunked: '#{te}'"
423
421
  elsif !te_valid
424
422
  raise HttpParserError501, "#{TE_ERR_MSG}, unknown value: '#{te}'"
425
423
  end
424
+ @env.delete TRANSFER_ENCODING2
425
+ return setup_chunked_body body
426
426
  elsif te_lwr == CHUNKED
427
427
  @env.delete TRANSFER_ENCODING2
428
428
  return setup_chunked_body body
@@ -500,40 +500,36 @@ module Puma
500
500
  # after this
501
501
  remain = @body_remain
502
502
 
503
- if remain > CHUNK_SIZE
504
- want = CHUNK_SIZE
505
- else
506
- want = remain
507
- end
503
+ # don't bother with reading zero bytes
504
+ unless remain.zero?
505
+ begin
506
+ chunk = @io.read_nonblock(remain.clamp(0, CHUNK_SIZE), @read_buffer)
507
+ rescue IO::WaitReadable
508
+ return false
509
+ rescue SystemCallError, IOError
510
+ raise ConnectionError, "Connection error detected during read"
511
+ end
508
512
 
509
- begin
510
- chunk = @io.read_nonblock(want, @read_buffer)
511
- rescue IO::WaitReadable
512
- return false
513
- rescue SystemCallError, IOError
514
- raise ConnectionError, "Connection error detected during read"
515
- end
513
+ # No chunk means a closed socket
514
+ unless chunk
515
+ @body.close
516
+ @buffer = nil
517
+ set_ready
518
+ raise EOFError
519
+ end
516
520
 
517
- # No chunk means a closed socket
518
- unless chunk
519
- @body.close
520
- @buffer = nil
521
- set_ready
522
- raise EOFError
521
+ remain -= @body.write(chunk)
523
522
  end
524
523
 
525
- remain -= @body.write(chunk)
526
-
527
524
  if remain <= 0
528
525
  @body.rewind
529
526
  @buffer = nil
530
527
  set_ready
531
- return true
528
+ true
529
+ else
530
+ @body_remain = remain
531
+ false
532
532
  end
533
-
534
- @body_remain = remain
535
-
536
- false
537
533
  end
538
534
 
539
535
  def read_chunked_body
@@ -14,7 +14,7 @@ module Puma
14
14
  class Worker < Puma::Runner # :nodoc:
15
15
  attr_reader :index, :master
16
16
 
17
- def initialize(index:, master:, launcher:, pipes:, server: nil)
17
+ def initialize(index:, master:, launcher:, pipes:, app: nil)
18
18
  super(launcher)
19
19
 
20
20
  @index = index
@@ -23,7 +23,8 @@ module Puma
23
23
  @worker_write = pipes[:worker_write]
24
24
  @fork_pipe = pipes[:fork_pipe]
25
25
  @wakeup = pipes[:wakeup]
26
- @server = server
26
+ @app = app
27
+ @server = nil
27
28
  @hook_data = {}
28
29
  end
29
30
 
@@ -57,7 +58,7 @@ module Puma
57
58
  @config.run_hooks(:before_worker_boot, index, @log_writer, @hook_data)
58
59
 
59
60
  begin
60
- server = @server ||= start_server
61
+ @server = start_server
61
62
  rescue Exception => e
62
63
  log "! Unable to start worker"
63
64
  log e
@@ -85,7 +86,7 @@ module Puma
85
86
  if idx == -1 # stop server
86
87
  if restart_server.length > 0
87
88
  restart_server.clear
88
- server.begin_restart(true)
89
+ @server.begin_restart(true)
89
90
  @config.run_hooks(:before_refork, nil, @log_writer, @hook_data)
90
91
  end
91
92
  elsif idx == -2 # refork cycle is done
@@ -103,20 +104,19 @@ module Puma
103
104
  Signal.trap "SIGTERM" do
104
105
  @worker_write << "#{PIPE_EXTERNAL_TERM}#{Process.pid}\n" rescue nil
105
106
  restart_server.clear
106
- server.stop
107
+ @server.stop
107
108
  restart_server << false
108
109
  end
109
110
 
110
111
  begin
111
112
  @worker_write << "#{PIPE_BOOT}#{Process.pid}:#{index}\n"
112
113
  rescue SystemCallError, IOError
113
- Puma::Util.purge_interrupt_queue
114
114
  STDERR.puts "Master seems to have exited, exiting."
115
115
  return
116
116
  end
117
117
 
118
118
  while restart_server.pop
119
- server_thread = server.run
119
+ server_thread = @server.run
120
120
 
121
121
  if @log_writer.debug? && index == 0
122
122
  debug_loaded_extensions "Loaded Extensions - worker 0:"
@@ -128,16 +128,16 @@ module Puma
128
128
 
129
129
  while true
130
130
  begin
131
- b = server.backlog || 0
132
- r = server.running || 0
133
- t = server.pool_capacity || 0
134
- m = server.max_threads || 0
135
- rc = server.requests_count || 0
136
- bt = server.busy_threads || 0
137
- payload = %Q!#{base_payload}{ "backlog":#{b}, "running":#{r}, "pool_capacity":#{t}, "max_threads":#{m}, "requests_count":#{rc}, "busy_threads":#{bt} }\n!
138
- io << payload
131
+ payload = base_payload.dup
132
+
133
+ hsh = @server.stats
134
+ hsh.each do |k, v|
135
+ payload << %Q! "#{k}":#{v || 0},!
136
+ end
137
+ # sub call properly adds 'closing' string
138
+ io << payload.sub(/,\z/, " }\n")
139
+ @server.reset_max
139
140
  rescue IOError
140
- Puma::Util.purge_interrupt_queue
141
141
  break
142
142
  end
143
143
  sleep @options[:worker_check_interval]
@@ -165,7 +165,7 @@ module Puma
165
165
  launcher: @launcher,
166
166
  pipes: { check_pipe: @check_pipe,
167
167
  worker_write: @worker_write },
168
- server: @server
168
+ app: @app
169
169
  new_worker.run
170
170
  end
171
171
 
@@ -4,13 +4,15 @@ module Puma
4
4
  class Cluster < Runner
5
5
  #—————————————————————— DO NOT USE — this class is for internal use only ———
6
6
 
7
-
8
7
  # This class represents a worker process from the perspective of the puma
9
8
  # master process. It contains information about the process and its health
10
9
  # and it exposes methods to control the process via IPC. It does not
11
10
  # include the actual logic executed by the worker process itself. For that,
12
11
  # see Puma::Cluster::Worker.
13
12
  class WorkerHandle # :nodoc:
13
+ # array of stat 'max' keys
14
+ WORKER_MAX_KEYS = [:backlog_max, :reactor_max]
15
+
14
16
  def initialize(idx, pid, phase, options)
15
17
  @index = idx
16
18
  @pid = pid
@@ -23,12 +25,13 @@ module Puma
23
25
  @last_checkin = Time.now
24
26
  @last_status = {}
25
27
  @term = false
28
+ @worker_max = Array.new WORKER_MAX_KEYS.length, 0
26
29
  end
27
30
 
28
- attr_reader :index, :pid, :phase, :signal, :last_checkin, :last_status, :started_at
31
+ attr_reader :index, :pid, :phase, :signal, :last_checkin, :last_status, :started_at, :process_status
29
32
 
30
33
  # @version 5.0.0
31
- attr_writer :pid, :phase
34
+ attr_writer :pid, :phase, :process_status
32
35
 
33
36
  def booted?
34
37
  @stage == :booted
@@ -51,12 +54,40 @@ module Puma
51
54
  @term
52
55
  end
53
56
 
54
- STATUS_PATTERN = /{ "backlog":(?<backlog>\d*), "running":(?<running>\d*), "pool_capacity":(?<pool_capacity>\d*), "max_threads":(?<max_threads>\d*), "requests_count":(?<requests_count>\d*), "busy_threads":(?<busy_threads>\d*) }/
55
- private_constant :STATUS_PATTERN
56
-
57
57
  def ping!(status)
58
+ hsh = {}
59
+ k, v = nil, nil
60
+ status.tr('}{"', '').strip.split(", ") do |kv|
61
+ cntr = 0
62
+ kv.split(':') do |t|
63
+ if cntr == 0
64
+ k = t
65
+ cntr = 1
66
+ else
67
+ v = t
68
+ end
69
+ end
70
+ hsh[k.to_sym] = v.to_i
71
+ end
72
+
73
+ # check stat max values, we can't signal workers to reset the max values,
74
+ # so we do so here
75
+ WORKER_MAX_KEYS.each_with_index do |key, idx|
76
+ next unless hsh[key]
77
+
78
+ if hsh[key] < @worker_max[idx]
79
+ hsh[key] = @worker_max[idx]
80
+ else
81
+ @worker_max[idx] = hsh[key]
82
+ end
83
+ end
58
84
  @last_checkin = Time.now
59
- @last_status = status.match(STATUS_PATTERN).named_captures.map { |c_name, c| [c_name.to_sym, c.to_i] }.to_h
85
+ @last_status = hsh
86
+ end
87
+
88
+ # Resets max values to zero. Called whenever `Cluster#stats` is called
89
+ def reset_max
90
+ WORKER_MAX_KEYS.length.times { |idx| @worker_max[idx] = 0 }
60
91
  end
61
92
 
62
93
  # @see Puma::Cluster#check_workers
data/lib/puma/cluster.rb CHANGED
@@ -22,7 +22,8 @@ module Puma
22
22
  @workers = []
23
23
  @next_check = Time.now
24
24
 
25
- @phased_restart = false
25
+ @worker_max = [] # keeps track of 'max' stat values
26
+ @pending_phased_restart = false
26
27
  end
27
28
 
28
29
  # Returns the list of cluster worker handles.
@@ -45,8 +46,7 @@ module Puma
45
46
  end
46
47
 
47
48
  def start_phased_restart(refork = false)
48
- @events.fire_on_restart!
49
-
49
+ @events.fire_before_restart!
50
50
  @phase += 1
51
51
  if refork
52
52
  log "- Starting worker refork, phase: #{@phase}"
@@ -186,7 +186,7 @@ module Puma
186
186
  # we need to phase any workers out (which will restart
187
187
  # in the right phase).
188
188
  #
189
- w = @workers.find { |x| x.phase != @phase }
189
+ w = @workers.find { |x| x.phase < @phase }
190
190
 
191
191
  if w
192
192
  if refork
@@ -221,12 +221,11 @@ module Puma
221
221
  pipes[:wakeup] = @wakeup
222
222
  end
223
223
 
224
- server = start_server if preload?
225
224
  new_worker = Worker.new index: index,
226
225
  master: master,
227
226
  launcher: @launcher,
228
227
  pipes: pipes,
229
- server: server
228
+ app: (app if preload?)
230
229
  new_worker.run
231
230
  end
232
231
 
@@ -238,7 +237,7 @@ module Puma
238
237
  def phased_restart(refork = false)
239
238
  return false if @options[:preload_app] && !refork
240
239
 
241
- @phased_restart = refork ? :refork : true
240
+ @pending_phased_restart = refork ? :refork : true
242
241
  wakeup!
243
242
 
244
243
  true
@@ -268,11 +267,14 @@ module Puma
268
267
  end
269
268
 
270
269
  # Inside of a child process, this will return all zeroes, as @workers is only populated in
271
- # the master process.
270
+ # the master process. Calling this also resets stat 'max' values to zero.
272
271
  # @!attribute [r] stats
272
+ # @return [Hash]
273
+
273
274
  def stats
274
275
  old_worker_count = @workers.count { |w| w.phase != @phase }
275
276
  worker_status = @workers.map do |w|
277
+ w.reset_max
276
278
  {
277
279
  started_at: utc_iso8601(w.started_at),
278
280
  pid: w.pid,
@@ -283,7 +285,6 @@ module Puma
283
285
  last_status: w.last_status,
284
286
  }
285
287
  end
286
-
287
288
  {
288
289
  started_at: utc_iso8601(@started_at),
289
290
  workers: @workers.size,
@@ -352,7 +353,7 @@ module Puma
352
353
 
353
354
  stop_workers
354
355
  stop
355
- @events.fire_on_stopped!
356
+ @events.fire_after_stopped!
356
357
  raise(SignalException, "SIGTERM") if @options[:raise_exception_on_sigterm]
357
358
  exit 0 # Clean exit, workers were stopped
358
359
  end
@@ -369,12 +370,8 @@ module Puma
369
370
 
370
371
  if preload?
371
372
  # Threads explicitly marked as fork safe will be ignored. Used in Rails,
372
- # but may be used by anyone. Note that we need to explicit
373
- # Process::Waiter check here because there's a bug in Ruby 2.6 and below
374
- # where calling thread_variable_get on a Process::Waiter will segfault.
375
- # We can drop that clause once those versions of Ruby are no longer
376
- # supported.
377
- fork_safe = ->(t) { !t.is_a?(Process::Waiter) && t.thread_variable_get(:fork_safe) }
373
+ # but may be used by anyone.
374
+ fork_safe = ->(t) { t.thread_variable_get(:fork_safe) }
378
375
 
379
376
  before = Thread.list.reject(&fork_safe)
380
377
 
@@ -423,6 +420,7 @@ module Puma
423
420
 
424
421
  log "Use Ctrl-C to stop"
425
422
 
423
+ warn_ruby_mn_threads
426
424
  single_worker_warning
427
425
 
428
426
  redirect_io
@@ -457,11 +455,11 @@ module Puma
457
455
  break
458
456
  end
459
457
 
460
- if @phased_restart
461
- start_phased_restart(@phased_restart == :refork)
458
+ if @pending_phased_restart
459
+ start_phased_restart(@pending_phased_restart == :refork)
462
460
 
463
- in_phased_restart = @phased_restart
464
- @phased_restart = false
461
+ in_phased_restart = @pending_phased_restart
462
+ @pending_phased_restart = false
465
463
 
466
464
  workers_not_booted = @options[:workers]
467
465
  # worker 0 is not restarted on refork
@@ -511,7 +509,7 @@ module Puma
511
509
  end
512
510
 
513
511
  if !booted && @workers.none? {|worker| worker.last_status.empty?}
514
- @events.fire_on_booted!
512
+ @events.fire_after_booted!
515
513
  debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug?
516
514
  booted = true
517
515
  end
@@ -528,7 +526,7 @@ module Puma
528
526
  end
529
527
 
530
528
  if in_phased_restart && workers_not_booted.zero?
531
- @events.fire_on_booted!
529
+ @events.fire_after_booted!
532
530
  debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug?
533
531
  in_phased_restart = false
534
532
  end
@@ -584,7 +582,9 @@ module Puma
584
582
  # `Process.wait2(-1)` from detecting a terminated process: https://bugs.ruby-lang.org/issues/19837.
585
583
  # 2. When `fork_worker` is enabled, some worker may not be direct children,
586
584
  # but grand children. Because of this they won't be reaped by `Process.wait2(-1)`.
587
- if reaped_children.delete(w.pid) || Process.wait(w.pid, Process::WNOHANG)
585
+ if (status = reaped_children.delete(w.pid) || Process.wait2(w.pid, Process::WNOHANG)&.last)
586
+ w.process_status = status
587
+ @config.run_hooks(:after_worker_shutdown, w, @log_writer)
588
588
  true
589
589
  else
590
590
  w.term if w.term?
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ # Calculate a delay value for sleeping when running in clustered mode
5
+ #
6
+ # The main reason this is a class is so it can be unit tested independently.
7
+ # This makes modification easier in the future if we can encode properties of the
8
+ # delay into a test instead of relying on end-to-end testing only.
9
+ #
10
+ # This is an imprecise mechanism to address specific goals:
11
+ #
12
+ # - Evenly distribute requests across all workers at start
13
+ # - Evenly distribute CPU resources across all workers
14
+ #
15
+ # ## Goal: Distribute requests across workers at start
16
+ #
17
+ # There was a perf bug in Puma where one worker would wake up slightly before the rest and accept
18
+ # all the requests on the socket even though it didn't have enough resources to process all of them.
19
+ # This was originally fixed by never calling accept when a worker had more requests than threads
20
+ # already https://github.com/puma/puma/pull/3678/files/2736ebddb3fc8528e5150b5913fba251c37a8bf7#diff-a95f46e7ce116caddc9b9a9aa81004246d5210d5da5f4df90a818c780630166bL251-L291
21
+ #
22
+ # With the introduction of true keepalive support, there are two ways a request can come in:
23
+ # - A new request from a new client comes into the socket and it must be "accept"-ed
24
+ # - A keepalive request is served and the connection is retained. Another request is then accepted
25
+ #
26
+ # Ideally the server handles requests in the order they come in, and ideally it doesn't accept more requests than it can handle.
27
+ # These goals are contradictory, because when the server is at maximum capacity due to keepalive connections, it could mean we
28
+ # block all new requests, even if those came in before the new request on the older keepalive connection.
29
+ #
30
+ # ## Goal: Distribute CPU resources across all workers
31
+ #
32
+ # - This issue was opened https://github.com/puma/puma/issues/2078
33
+ #
34
+ # There are several entangled issues and it's not exactly clear what the root cause is, but the observable outcome
35
+ # was that performance was better with a small sleep, and that eventually became the default.
36
+ #
37
+ # An attempt to describe why this works is here: https://github.com/puma/puma/issues/2078#issuecomment-3287032470.
38
+ #
39
+ # Summarizing: The delay is for tuning the rate at which "accept" is called on the socket.
40
+ # Puma works by calling "accept" nonblock on the socket in a loop. When there are multiple workers
41
+ # (processes), they will "race" to accept a request at roughly the same rate. However, if one
42
+ # worker has all threads busy processing requests, then accepting a new request might "steal" it from
43
+ # a less busy worker. If a worker has no work to do, it should loop as fast as possible.
44
+ #
45
+ # ## Solution: Distribute requests across workers at start
46
+ #
47
+ # For now, both goals are framed as "load balancing" across workers (processes) and achieved through
48
+ # the same mechanism of sleeping longer to delay busier workers. Rather than the prior Puma 6.x
49
+ # and earlier behavior of using a binary on/off sleep value, we increase it an amount proportional
50
+ # to the load the server is under, capping the maximum delay to the scenario where all threads are busy
51
+ # and the todo list has reached a multiplier of the maximum number of threads.
52
+ #
53
+ # Private: API may change unexpectedly
54
+ class ClusterAcceptLoopDelay
55
+ attr_reader :max_delay
56
+
57
+ # Initialize happens once, `call` happens often. Perform global calculations here.
58
+ def initialize(
59
+ # Number of workers in the cluster
60
+ workers: ,
61
+ # Maximum delay in seconds i.e. 0.005 is 5 milliseconds
62
+ max_delay:
63
+ )
64
+ @on = max_delay > 0 && workers >= 2
65
+ @max_delay = max_delay.to_f
66
+
67
+ # Reach maximum delay when `max_threads * overload_multiplier` is reached in the system
68
+ @overload_multiplier = 25.0
69
+ end
70
+
71
+ def on?
72
+ @on
73
+ end
74
+
75
+ # We want the extreme values of this delay to be known (minimum and maximum) as well as
76
+ # a predictable curve between the two. i.e. no step functions or hard cliffs.
77
+ #
78
+ # Return value is always numeric. Returns 0 if there should be no delay.
79
+ def calculate(
80
+ # Number of threads working right now, plus number of requests in the todo list
81
+ busy_threads_plus_todo:,
82
+ # Maximum number of threads in the pool, note that the busy threads (alone) may go over this value at times
83
+ # if the pool needs to be reaped. The busy thread plus todo count may go over this value by a large amount.
84
+ max_threads:
85
+ )
86
+ max_value = @overload_multiplier * max_threads
87
+ # Approaches max delay when `busy_threads_plus_todo` approaches `max_value`
88
+ return max_delay * busy_threads_plus_todo.clamp(0, max_value) / max_value
89
+ end
90
+ end
91
+ end
@@ -29,13 +29,13 @@ module Puma
29
29
 
30
30
  CONTENT_LENGTH = 'Content-Length' # should be lower case from app,
31
31
  # Util::HeaderHash allows mixed
32
- HTTP_VERSION = Const::HTTP_VERSION
33
32
  HTTP_X_FORWARDED_FOR = Const::HTTP_X_FORWARDED_FOR
34
33
  PATH_INFO = Const::PATH_INFO
35
34
  QUERY_STRING = Const::QUERY_STRING
36
35
  REMOTE_ADDR = Const::REMOTE_ADDR
37
36
  REMOTE_USER = 'REMOTE_USER'
38
37
  REQUEST_METHOD = Const::REQUEST_METHOD
38
+ SERVER_PROTOCOL = Const::SERVER_PROTOCOL
39
39
 
40
40
  def initialize(app, logger=nil)
41
41
  @app = app
@@ -70,7 +70,7 @@ module Puma
70
70
  env[REQUEST_METHOD],
71
71
  env[PATH_INFO],
72
72
  env[QUERY_STRING].empty? ? "" : "?#{env[QUERY_STRING]}",
73
- env[HTTP_VERSION],
73
+ env[SERVER_PROTOCOL],
74
74
  now - began_at ]
75
75
 
76
76
  write(msg)
@@ -87,7 +87,7 @@ module Puma
87
87
  env[REQUEST_METHOD],
88
88
  env[PATH_INFO],
89
89
  env[QUERY_STRING].empty? ? "" : "?#{env[QUERY_STRING]}",
90
- env[HTTP_VERSION],
90
+ env[SERVER_PROTOCOL],
91
91
  status.to_s[0..3],
92
92
  length,
93
93
  now - began_at ]