puma-plus 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/exe/puma-plus +12 -0
- data/exe/puma-plus-worker +79 -0
- data/ext/puma_plus_gvl/extconf.rb +16 -0
- data/ext/puma_plus_gvl/puma_plus_gvl.c +160 -0
- data/lib/puma_plus/address.rb +33 -0
- data/lib/puma_plus/app_loader.rb +66 -0
- data/lib/puma_plus/config_file.rb +267 -0
- data/lib/puma_plus/control_channel.rb +138 -0
- data/lib/puma_plus/gvl.rb +76 -0
- data/lib/puma_plus/hooks.rb +88 -0
- data/lib/puma_plus/input.rb +216 -0
- data/lib/puma_plus/launcher.rb +347 -0
- data/lib/puma_plus/rack_env.rb +92 -0
- data/lib/puma_plus/ractor_worker.rb +227 -0
- data/lib/puma_plus/shepherd.rb +234 -0
- data/lib/puma_plus/version.rb +5 -0
- data/lib/puma_plus/wire.rb +236 -0
- data/lib/puma_plus/worker.rb +90 -0
- data/lib/puma_plus/worker_thread.rb +331 -0
- data/lib/puma_plus/ws.rb +233 -0
- data/lib/puma_plus/ws_event.rb +78 -0
- metadata +85 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "puma_plus/config_file"
|
|
5
|
+
require "puma_plus/version"
|
|
6
|
+
|
|
7
|
+
module PumaPlus
|
|
8
|
+
# The `puma-plus` command.
|
|
9
|
+
#
|
|
10
|
+
# Ruby parses the configuration and then *execs* the Go server. Two decisions
|
|
11
|
+
# worth stating, because both could reasonably have gone the other way.
|
|
12
|
+
#
|
|
13
|
+
# Why Ruby reads the config: it is a Ruby file. Worker counts get computed from
|
|
14
|
+
# Etc.nprocessors, settings branch on the environment, and lifecycle hooks are
|
|
15
|
+
# blocks. Only Ruby can evaluate that, and a config format Go could parse
|
|
16
|
+
# would be a worse config format.
|
|
17
|
+
#
|
|
18
|
+
# Why exec rather than spawn: exec replaces this process, so the Go server ends
|
|
19
|
+
# up with the pid the shell started, inherits the terminal and the process
|
|
20
|
+
# group directly, and receives signals from the user with nothing in between.
|
|
21
|
+
# A supervising Ruby parent would have to forward signals, mirror exit
|
|
22
|
+
# statuses, and would show up as a stray process in every `ps` -- all to
|
|
23
|
+
# supervise a process that supervises Ruby itself. After exec there is no Ruby
|
|
24
|
+
# in the tree except the workers the Go server starts.
|
|
25
|
+
class Launcher
|
|
26
|
+
DEFAULTS = {
|
|
27
|
+
listen: "0.0.0.0:9292",
|
|
28
|
+
control: "127.0.0.1:9293",
|
|
29
|
+
app: "config.ru",
|
|
30
|
+
threads: 5,
|
|
31
|
+
workers: 1,
|
|
32
|
+
environment: "development"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# Searched in order, mirroring puma's config/puma/<env>.rb then config/puma.rb.
|
|
36
|
+
def self.config_candidates(env)
|
|
37
|
+
["config/puma-plus/#{env}.rb", "config/puma-plus.rb"]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(argv, stdout: $stdout, stderr: $stderr, env: ENV)
|
|
41
|
+
@argv = argv
|
|
42
|
+
@stdout = stdout
|
|
43
|
+
@stderr = stderr
|
|
44
|
+
@env = env
|
|
45
|
+
@options = Options.new(DEFAULTS.dup)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attr_reader :options
|
|
49
|
+
|
|
50
|
+
def run
|
|
51
|
+
parse_cli!
|
|
52
|
+
|
|
53
|
+
# RACK_ENV is consulted before the config file is chosen, because which
|
|
54
|
+
# file gets loaded depends on the environment.
|
|
55
|
+
if (e = @env["RACK_ENV"]) && !@options.user.key?(:environment)
|
|
56
|
+
@options.default[:environment] = e
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
dsl = load_config!
|
|
60
|
+
Dir.chdir(@options[:directory]) if @options[:directory]
|
|
61
|
+
|
|
62
|
+
(dsl&.warnings || []).each { |w| @stderr.puts "[puma-plus] config: #{w}" }
|
|
63
|
+
validate!
|
|
64
|
+
|
|
65
|
+
argv = go_argv
|
|
66
|
+
if @dry_run
|
|
67
|
+
print_plan(argv)
|
|
68
|
+
return 0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
write_pidfile
|
|
72
|
+
exec_go(argv)
|
|
73
|
+
rescue ConfigError, URI::Error, OptionParser::ParseError, ArgumentError => e
|
|
74
|
+
@stderr.puts "[puma-plus] #{e.message}"
|
|
75
|
+
1
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def parse_cli!
|
|
81
|
+
u = @options.user
|
|
82
|
+
|
|
83
|
+
OptionParser.new do |o|
|
|
84
|
+
o.banner = "usage: puma-plus [options] [rackup file]"
|
|
85
|
+
|
|
86
|
+
o.on("-C", "--config PATH", "config file (default: config/puma-plus.rb)") { |v| @config_path = v }
|
|
87
|
+
o.on("--no-config", "skip config file discovery") { @no_config = true }
|
|
88
|
+
o.on("-b", "--bind URL", "tcp://host:port or ssl://host:port") { |v| @bind = v }
|
|
89
|
+
o.on("-p", "--port PORT", Integer, "port to bind (default 9292)") { |v| @port = v }
|
|
90
|
+
o.on("-e", "--environment ENV", "environment (default development)") { |v| u[:environment] = v }
|
|
91
|
+
o.on("-t", "--threads N", "worker connections per process") { |v| u[:threads] = Integer(v.split(":").last) }
|
|
92
|
+
o.on("-w", "--workers N", Integer, "worker processes") { |v| u[:workers] = v }
|
|
93
|
+
o.on("--ractors N", Integer, "serve from N Ractors in one process") { |v| u[:ractors] = v }
|
|
94
|
+
o.on("--autoscale", "scale workers from measured queue time") { u[:autoscale] = true }
|
|
95
|
+
o.on("--min-workers N", Integer) { |v| u[:min_workers] = v }
|
|
96
|
+
o.on("--max-workers N", Integer) { |v| u[:max_workers] = v }
|
|
97
|
+
o.on("--target-queue-p95 D", "queue-time p95 to steer toward, e.g. 25ms") { |v| u[:target_queue_p95] = v }
|
|
98
|
+
o.on("--control-url URL", "--control URL", "where /stats and /metrics listen") do |v|
|
|
99
|
+
u[:control] = Address.host_port(v, 9293, "--control-url")
|
|
100
|
+
end
|
|
101
|
+
o.on("--tls-cert PATH", "PEM certificate chain for the TLS/h3 listener") { |v| u[:tls_cert] = File.expand_path(v) }
|
|
102
|
+
o.on("--tls-key PATH", "PEM private key matching --tls-cert") { |v| u[:tls_key] = File.expand_path(v) }
|
|
103
|
+
o.on("--dir DIR", "chdir here before booting") { |v| u[:directory] = File.expand_path(v) }
|
|
104
|
+
o.on("--pidfile PATH") { |v| u[:pidfile] = v }
|
|
105
|
+
o.on("--dry-run", "print the resolved configuration and exit") { @dry_run = true }
|
|
106
|
+
o.on("-v", "--version") do
|
|
107
|
+
# Both halves, because they are separately installed and a mismatched
|
|
108
|
+
# pair is the failure worth being able to see at a glance.
|
|
109
|
+
@stdout.puts "puma-plus #{PumaPlus::VERSION}"
|
|
110
|
+
begin
|
|
111
|
+
require "puma_plus/core/version"
|
|
112
|
+
@stdout.puts "puma-plus-core #{PumaPlus::Core::VERSION}"
|
|
113
|
+
rescue LoadError
|
|
114
|
+
@stdout.puts "puma-plus-core (not installed)"
|
|
115
|
+
end
|
|
116
|
+
exit 0
|
|
117
|
+
end
|
|
118
|
+
o.on("-h", "--help") { @stdout.puts o; exit 0 }
|
|
119
|
+
end.parse!(@argv)
|
|
120
|
+
|
|
121
|
+
# A bare argument is the rackup file, as `puma config.ru` accepts.
|
|
122
|
+
u[:app] = @argv.shift if @argv.first
|
|
123
|
+
|
|
124
|
+
# -b and -p are resolved after parsing so their interaction is defined
|
|
125
|
+
# rather than order-dependent: an explicit --bind wins, and a lone --port
|
|
126
|
+
# keeps whatever host the bind would have used.
|
|
127
|
+
if @bind
|
|
128
|
+
scheme = @bind[%r{\A([a-zA-Z][a-zA-Z0-9+.-]*)://}, 1] || "tcp"
|
|
129
|
+
case scheme
|
|
130
|
+
when "tcp", "http" then u[:listen] = Address.host_port(@bind, @port || 9292, "--bind")
|
|
131
|
+
when "ssl", "https" then u[:listen_tls] = Address.host_port(@bind, @port || 9443, "--bind")
|
|
132
|
+
else raise ConfigError, "--bind #{@bind.inspect}: unsupported scheme #{scheme.inspect} " \
|
|
133
|
+
"(use tcp:// or ssl://)"
|
|
134
|
+
end
|
|
135
|
+
elsif @port
|
|
136
|
+
u[:listen] = "0.0.0.0:#{@port}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def load_config!
|
|
141
|
+
return nil if @no_config
|
|
142
|
+
|
|
143
|
+
path = @config_path || self.class.config_candidates(@options[:environment]).find { |f| File.exist?(f) }
|
|
144
|
+
return nil unless path
|
|
145
|
+
|
|
146
|
+
unless File.exist?(path)
|
|
147
|
+
raise ConfigError, "config file not found: #{path}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
@config_used = path
|
|
151
|
+
ConfigFile.load(path, @options)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def validate!
|
|
155
|
+
if @options[:ractors].to_i.positive?
|
|
156
|
+
if @options[:workers].to_i > 1
|
|
157
|
+
raise ConfigError, "ractors and workers cannot both be set: Ractor mode is " \
|
|
158
|
+
"one process. Use one or the other."
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
unless File.exist?(@options[:app].to_s)
|
|
163
|
+
raise ConfigError, "rackup file not found: #{@options[:app]} " \
|
|
164
|
+
"(pass one as an argument, or set `rackup` in the config)"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
validate_tls!
|
|
168
|
+
|
|
169
|
+
min = @options[:min_workers]
|
|
170
|
+
max = @options[:max_workers]
|
|
171
|
+
if min && max && min > max
|
|
172
|
+
raise ConfigError, "min_workers (#{min}) must be <= max_workers (#{max})"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Checked here rather than left to the server, because an unreadable path is
|
|
177
|
+
# a typo in a config file and should be reported by the thing that read it.
|
|
178
|
+
def validate_tls!
|
|
179
|
+
cert = @options[:tls_cert]
|
|
180
|
+
key = @options[:tls_key]
|
|
181
|
+
|
|
182
|
+
if (cert && !key) || (key && !cert)
|
|
183
|
+
raise ConfigError, "tls_cert and tls_key must be given together " \
|
|
184
|
+
"(got only #{cert ? 'tls_cert' : 'tls_key'})"
|
|
185
|
+
end
|
|
186
|
+
return unless cert
|
|
187
|
+
|
|
188
|
+
{ "tls_cert" => cert, "tls_key" => key }.each do |what, path|
|
|
189
|
+
raise ConfigError, "#{what}: no such file: #{path}" unless File.exist?(path)
|
|
190
|
+
raise ConfigError, "#{what}: not readable: #{path}" unless File.readable?(path)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if @options[:listen_tls].nil? && @options[:listen_h3].nil?
|
|
194
|
+
@stderr.puts "[puma-plus] config: tls_cert is set but no TLS or HTTP/3 " \
|
|
195
|
+
"listener is configured; it will not be used"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Translate resolved options into the Go server's flags.
|
|
200
|
+
#
|
|
201
|
+
# One-way on purpose: the Go binary's flags stay the machine interface and
|
|
202
|
+
# the DSL stays the human one, so neither constrains the other's naming.
|
|
203
|
+
def go_argv
|
|
204
|
+
o = @options
|
|
205
|
+
args = []
|
|
206
|
+
|
|
207
|
+
args += ["-app", File.expand_path(o[:app])]
|
|
208
|
+
args += ["-control", o[:control]] if o[:control]
|
|
209
|
+
args += ["-threads", o[:threads].to_s] if o[:threads]
|
|
210
|
+
|
|
211
|
+
if o[:ractors].to_i.positive?
|
|
212
|
+
args += ["-ractors", o[:ractors].to_s]
|
|
213
|
+
else
|
|
214
|
+
args += ["-workers", o[:workers].to_s] if o[:workers]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# A TLS or HTTP/3 listener replaces the cleartext one unless both were
|
|
218
|
+
# asked for; -listen "" is how the Go side is told not to open it.
|
|
219
|
+
if o[:listen_tls]
|
|
220
|
+
args += ["-listen-tls", o[:listen_tls]]
|
|
221
|
+
args += ["-listen", o.origin(:listen) == :default ? "" : o[:listen]]
|
|
222
|
+
else
|
|
223
|
+
args += ["-listen", o[:listen]]
|
|
224
|
+
end
|
|
225
|
+
args += ["-h2c"] if o[:h2c]
|
|
226
|
+
args += ["-listen-h3", o[:listen_h3]] if o[:listen_h3]
|
|
227
|
+
args += ["-tls-hosts", o[:tls_hosts]] if o[:tls_hosts]
|
|
228
|
+
if o[:tls_cert] && o[:tls_key]
|
|
229
|
+
args += ["-tls-cert", o[:tls_cert], "-tls-key", o[:tls_key]]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
if o[:autoscale]
|
|
233
|
+
args << "-autoscale"
|
|
234
|
+
args += ["-min-workers", o[:min_workers].to_s] if o[:min_workers]
|
|
235
|
+
args += ["-max-workers", o[:max_workers].to_s] if o[:max_workers]
|
|
236
|
+
args += ["-target-queue-p95", o[:target_queue_p95]] if o[:target_queue_p95]
|
|
237
|
+
end
|
|
238
|
+
args += ["-mem-limit-mb", o[:mem_limit_mb].to_s] if o[:mem_limit_mb]
|
|
239
|
+
args += ["-decision-log", o[:decision_log]] if o[:decision_log]
|
|
240
|
+
args += ["-max-conns", o[:max_conns].to_s] if o[:max_conns]
|
|
241
|
+
args += ["-acquire-timeout", o[:acquire_timeout]] if o[:acquire_timeout]
|
|
242
|
+
args += ["-debug-headers"] if o[:debug_headers]
|
|
243
|
+
args += ["-websockets=false"] if o[:websockets] == false
|
|
244
|
+
args += ["-webtransport=false"] if o[:webtransport] == false
|
|
245
|
+
|
|
246
|
+
args
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The worker script this gem ships, in whichever layout we are in.
|
|
250
|
+
def worker_script
|
|
251
|
+
here = File.expand_path("../..", __dir__) # gem/lib/puma_plus -> gem
|
|
252
|
+
%w[exe/puma-plus-worker ../gem/exe/puma-plus-worker].each do |rel|
|
|
253
|
+
cand = File.expand_path(rel, here)
|
|
254
|
+
return cand if File.exist?(cand)
|
|
255
|
+
end
|
|
256
|
+
raise ConfigError, "cannot find puma-plus-worker next to #{__dir__}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Load paths the worker needs: this gem's lib, plus the GVL extension if it
|
|
260
|
+
# was built in place (a checkout). An installed gem puts the compiled
|
|
261
|
+
# extension on the default load path already, so the extra entry is absent
|
|
262
|
+
# rather than wrong.
|
|
263
|
+
def ruby_load_path
|
|
264
|
+
lib = File.expand_path("../..", __dir__)
|
|
265
|
+
paths = [lib]
|
|
266
|
+
ext = File.expand_path("../ext/puma_plus_gvl", lib)
|
|
267
|
+
paths << ext if File.directory?(ext)
|
|
268
|
+
paths
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Locate the compiled server.
|
|
272
|
+
#
|
|
273
|
+
# PUMA_PLUS_BINARY wins, then the puma-plus-core gem if it is installed, then
|
|
274
|
+
# a build sitting in the checkout. The core gem is how a real install finds
|
|
275
|
+
# it: the Go server ships there, precompiled for the platform.
|
|
276
|
+
def go_binary_from_core
|
|
277
|
+
require "puma_plus/core"
|
|
278
|
+
path = PumaPlus::Core.binary_path
|
|
279
|
+
path if path && File.executable?(path)
|
|
280
|
+
rescue LoadError
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def go_binary
|
|
285
|
+
env = @env["PUMA_PLUS_BINARY"]
|
|
286
|
+
return env if env && File.executable?(env)
|
|
287
|
+
|
|
288
|
+
if (core = go_binary_from_core)
|
|
289
|
+
return core
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
here = File.expand_path("../../..", __dir__)
|
|
293
|
+
%w[puma-plus bin/puma-plus].each do |rel|
|
|
294
|
+
cand = File.join(here, rel)
|
|
295
|
+
return cand if File.executable?(cand) && !File.directory?(cand)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
found = ENV["PATH"].to_s.split(File::PATH_SEPARATOR)
|
|
299
|
+
.map { |d| File.join(d, "puma-plus-server") }
|
|
300
|
+
.find { |f| File.executable?(f) }
|
|
301
|
+
return found if found
|
|
302
|
+
|
|
303
|
+
raise ConfigError, "cannot find the puma-plus server binary. Install the " \
|
|
304
|
+
"puma-plus-core gem, build it with " \
|
|
305
|
+
"`go build -o puma-plus ./cmd/puma-plus`, or set PUMA_PLUS_BINARY."
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def write_pidfile
|
|
309
|
+
return unless @options[:pidfile]
|
|
310
|
+
|
|
311
|
+
# Written before exec, so it holds the pid the Go server will have -- exec
|
|
312
|
+
# keeps this process id. Writing it after would be impossible; writing a
|
|
313
|
+
# child's pid would be wrong.
|
|
314
|
+
File.write(@options[:pidfile], "#{Process.pid}\n")
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def exec_go(args)
|
|
318
|
+
bin = go_binary
|
|
319
|
+
@stderr.puts "[puma-plus] #{@config_used ? "config #{@config_used}, " : ''}" \
|
|
320
|
+
"env #{@options[:environment]}"
|
|
321
|
+
env = {}
|
|
322
|
+
env["RACK_ENV"] = @options[:environment].to_s
|
|
323
|
+
env["PUMA_PLUS_CONFIG"] = @config_used if @config_used
|
|
324
|
+
# This file lives inside the gem, so it knows where the worker script and
|
|
325
|
+
# the load path are. Telling the server outright is the only thing that
|
|
326
|
+
# works in both layouts: a checkout has gem/exe and gem/lib, an installed
|
|
327
|
+
# gem has exe/ and lib/, and nothing the server could compute is right for
|
|
328
|
+
# both.
|
|
329
|
+
env["PUMA_PLUS_WORKER"] = worker_script
|
|
330
|
+
env["PUMA_PLUS_RUBY_LIB"] = ruby_load_path.join(File::PATH_SEPARATOR)
|
|
331
|
+
Kernel.exec(env, bin, *args)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def print_plan(args)
|
|
335
|
+
@stdout.puts "config file : #{@config_used || '(none)'}"
|
|
336
|
+
@stdout.puts "environment : #{@options[:environment]}"
|
|
337
|
+
@stdout.puts "server : #{begin; go_binary; rescue ConfigError; '(not built)'; end}"
|
|
338
|
+
@stdout.puts
|
|
339
|
+
@stdout.puts "resolved options (origin):"
|
|
340
|
+
@options.to_h.keys.sort_by(&:to_s).each do |k|
|
|
341
|
+
@stdout.printf(" %-20s %-28s %s\n", k, @options[k].inspect, @options.origin(k))
|
|
342
|
+
end
|
|
343
|
+
@stdout.puts
|
|
344
|
+
@stdout.puts "exec: #{File.basename(begin; go_binary; rescue ConfigError; 'puma-plus'; end)} #{args.join(' ')}"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "puma_plus/wire"
|
|
4
|
+
|
|
5
|
+
module PumaPlus
|
|
6
|
+
# Assembles the Rack env from a REQUEST frame.
|
|
7
|
+
#
|
|
8
|
+
# Go emits the *complete, final* env in CGI form, so this class performs zero
|
|
9
|
+
# string transformation -- no dash/underscore mangling, no Host splitting, no
|
|
10
|
+
# case folding. All of that lives in internal/frontend/env.go. What remains
|
|
11
|
+
# here is a hash dup plus byteslice merges, which is the cheapest per-request
|
|
12
|
+
# env construction available without a C extension.
|
|
13
|
+
#
|
|
14
|
+
# Compare puma, which builds the env in five layers (Binder's @proto_env, the
|
|
15
|
+
# Ragel C parser writing straight into the hash, normalize_env,
|
|
16
|
+
# req_env_post_parse, then handle_request). Moving parsing to Go collapses that
|
|
17
|
+
# to one pass.
|
|
18
|
+
class RackEnv
|
|
19
|
+
# Keys that appear on essentially every request, pre-frozen so the common
|
|
20
|
+
# path never allocates a key string. Same optimization as puma's 35
|
|
21
|
+
# pre-interned header names in ext/puma_http11/puma_http11.c:78-118.
|
|
22
|
+
COMMON_KEYS = %w[
|
|
23
|
+
REQUEST_METHOD PATH_INFO SCRIPT_NAME QUERY_STRING SERVER_PROTOCOL
|
|
24
|
+
SERVER_NAME SERVER_PORT REMOTE_ADDR SERVER_SOFTWARE
|
|
25
|
+
CONTENT_LENGTH CONTENT_TYPE
|
|
26
|
+
rack.url_scheme
|
|
27
|
+
HTTP_HOST HTTP_ACCEPT HTTP_ACCEPT_CHARSET HTTP_ACCEPT_ENCODING
|
|
28
|
+
HTTP_ACCEPT_LANGUAGE HTTP_AUTHORIZATION HTTP_CACHE_CONTROL HTTP_CONNECTION
|
|
29
|
+
HTTP_COOKIE HTTP_IF_MODIFIED_SINCE HTTP_IF_NONE_MATCH HTTP_ORIGIN
|
|
30
|
+
HTTP_PRAGMA HTTP_REFERER HTTP_USER_AGENT HTTP_X_FORWARDED_FOR
|
|
31
|
+
HTTP_X_FORWARDED_PROTO HTTP_X_REQUEST_ID HTTP_X_REQUEST_START
|
|
32
|
+
].each_with_object({}) { |k, h| h[k.b.freeze] = k.freeze }.freeze
|
|
33
|
+
|
|
34
|
+
# Uncommon keys are deduplicated into this cache on first sight, so a long
|
|
35
|
+
# tail of custom headers costs one allocation per distinct name for the
|
|
36
|
+
# lifetime of the process rather than one per request.
|
|
37
|
+
# multithread and multiprocess describe THIS process, and are computed by the
|
|
38
|
+
# caller from the real configuration rather than assumed.
|
|
39
|
+
#
|
|
40
|
+
# Rack 3 dropped these from the spec, along with rack.version and
|
|
41
|
+
# rack.run_once. They are kept because apps still read them -- sizing a
|
|
42
|
+
# connection pool is the usual reason -- and because puma still sets them
|
|
43
|
+
# (puma/lib/puma/binder.rb:33-35). rack.version is NOT kept: Rack 3 removed
|
|
44
|
+
# it and any value would be a claim about a spec version that no longer
|
|
45
|
+
# numbers itself that way.
|
|
46
|
+
#
|
|
47
|
+
# Hardcoding them, as this used to, meant a single-threaded worker still
|
|
48
|
+
# announced rack.multithread, and an app sizing a pool from it over-allocated
|
|
49
|
+
# by exactly the factor it was trying to compute.
|
|
50
|
+
def initialize(errors: $stderr, multithread: true, multiprocess: false)
|
|
51
|
+
@key_cache = Hash.new { |h, k| h[k] = -k.dup.force_encoding(Encoding::UTF_8) }
|
|
52
|
+
@prototype = {
|
|
53
|
+
"rack.errors" => errors,
|
|
54
|
+
"rack.hijack?" => true,
|
|
55
|
+
"rack.multithread" => multithread,
|
|
56
|
+
"rack.multiprocess" => multiprocess,
|
|
57
|
+
"rack.run_once" => false
|
|
58
|
+
}.freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Build an env from a decoded env kv blob.
|
|
62
|
+
#
|
|
63
|
+
# +pairs+ is the array of [key, value] byteslices from Wire.decode_kv. Values
|
|
64
|
+
# are handed through as-is: they are byteslices of the single frame payload,
|
|
65
|
+
# so the whole env costs one payload string plus N slice headers.
|
|
66
|
+
def build(pairs, meta)
|
|
67
|
+
env = @prototype.dup
|
|
68
|
+
|
|
69
|
+
pairs.each do |key, value|
|
|
70
|
+
env[COMMON_KEYS[key] || @key_cache[key]] = value
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Timing, in the three shapes callers expect.
|
|
74
|
+
#
|
|
75
|
+
# puma.request_queue_time is ours and authoritative: nanoseconds measured
|
|
76
|
+
# entirely inside Go from a monotonic clock, so no cross-process skew.
|
|
77
|
+
#
|
|
78
|
+
# puma.request_body_wait keeps puma's exact key and millisecond unit
|
|
79
|
+
# (lib/puma/client.rb:746) so middleware written against puma works
|
|
80
|
+
# unchanged.
|
|
81
|
+
env["puma.request_queue_time"] = meta[:queue_ns] / 1_000_000_000.0
|
|
82
|
+
env["puma.request_body_wait"] = meta[:body_wait_ns] / 1_000_000.0
|
|
83
|
+
|
|
84
|
+
# X-Request-Start in the exact `t=<unix micros>` format New Relic, Scout
|
|
85
|
+
# and Skylight already parse, injected only if absent so an upstream load
|
|
86
|
+
# balancer's value always wins. Existing APM gems light up with no changes.
|
|
87
|
+
env["HTTP_X_REQUEST_START"] ||= "t=#{meta[:runnable_wall_us]}"
|
|
88
|
+
|
|
89
|
+
env
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "etc"
|
|
5
|
+
require "puma_plus/wire"
|
|
6
|
+
require "puma_plus/control_channel"
|
|
7
|
+
require "puma_plus/worker_thread"
|
|
8
|
+
require "puma_plus/app_loader"
|
|
9
|
+
require "puma_plus/hooks"
|
|
10
|
+
require "puma_plus/gvl"
|
|
11
|
+
|
|
12
|
+
module PumaPlus
|
|
13
|
+
# A worker process whose unit of capacity is a Ractor rather than a thread.
|
|
14
|
+
#
|
|
15
|
+
# Same contract as Worker: N units of capacity, each dialing the Go server and
|
|
16
|
+
# serving one request at a time. What differs is what a unit *is*. Threads
|
|
17
|
+
# share one lock, so N threads running CPU-bound Ruby is still one core's worth
|
|
18
|
+
# of throughput -- that is the entire reason this project scales by forking
|
|
19
|
+
# processes, and the reason the controller carries GVL instrumentation to
|
|
20
|
+
# detect when adding a thread would be pointless. Ractors each hold their own
|
|
21
|
+
# lock and genuinely run in parallel, so the actuator the controller wants
|
|
22
|
+
# ("add capacity") becomes available without a fork.
|
|
23
|
+
#
|
|
24
|
+
# This architecture is an unusually good fit for Ractors, and the reason is
|
|
25
|
+
# what is absent rather than what is present. The hard part of a Ractor web
|
|
26
|
+
# server is work distribution: Ractors cannot share a mutable queue, so cougar
|
|
27
|
+
# (github.com/jhawthorn/cougar) has every Ractor call accept() on one shared
|
|
28
|
+
# TCPServer and lets the kernel do the balancing. puma-plus has no listener and
|
|
29
|
+
# no queue in Ruby at all -- both are in Go -- so each Ractor dials its own
|
|
30
|
+
# unix socket and shares literally nothing with its siblings. There is no
|
|
31
|
+
# shared object to make shareable, and no accept-balancing to get wrong.
|
|
32
|
+
#
|
|
33
|
+
# Cost model versus forking a worker: a Ractor starts in roughly a millisecond
|
|
34
|
+
# against tens to hundreds for fork-plus-app-boot, and it starts *warm* --
|
|
35
|
+
# same heap, same JIT state, no re-require. Fork dead time is exactly what
|
|
36
|
+
# costs the autoscaler its p99 in the step-mix benchmark, so this is the
|
|
37
|
+
# actuator most likely to close that gap.
|
|
38
|
+
#
|
|
39
|
+
# The remaining constraint is the app, which must be deeply frozen to cross a
|
|
40
|
+
# Ractor boundary. That is a real limitation and it excludes most of the
|
|
41
|
+
# ecosystem today, Rails very much included. See #shareable!.
|
|
42
|
+
#
|
|
43
|
+
# Known gap for the spike: WS/WebTransport publishing (PumaPlus::WS) keeps
|
|
44
|
+
# module-level state in the main Ractor and is not reachable from inside one,
|
|
45
|
+
# so realtime apps must still use the threaded worker.
|
|
46
|
+
class RactorWorker
|
|
47
|
+
def initialize(socket_path:, app_path:, ractors:, worker_id: 0, config_path: nil,
|
|
48
|
+
logger: $stderr)
|
|
49
|
+
@socket_path = socket_path
|
|
50
|
+
@app_path = app_path
|
|
51
|
+
@ractors = ractors
|
|
52
|
+
@worker_id = worker_id
|
|
53
|
+
@logger = logger
|
|
54
|
+
@hooks = Hooks.load(config_path, logger: logger)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def run
|
|
58
|
+
run_preloaded(AppLoader.load(@app_path))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Serve using an already-loaded app, mirroring Worker#run_preloaded so a
|
|
62
|
+
# shepherd can preload once and hand the same object to either worker kind.
|
|
63
|
+
def run_preloaded(app)
|
|
64
|
+
# Ruby still prints an experimental-feature warning on first Ractor use.
|
|
65
|
+
# Scoped to this call rather than set globally, so warnings from anything
|
|
66
|
+
# else stay visible.
|
|
67
|
+
warning_was = Warning[:experimental]
|
|
68
|
+
Warning[:experimental] = false
|
|
69
|
+
|
|
70
|
+
app = shareable!(app)
|
|
71
|
+
|
|
72
|
+
# The event hook is VM-wide, so one registration covers every Ractor. What
|
|
73
|
+
# it measures changes meaning here: siblings no longer contend for a shared
|
|
74
|
+
# lock, so a rising GVL fraction now indicates contention *within* a
|
|
75
|
+
# Ractor rather than across the process.
|
|
76
|
+
PumaPlus::GVL.start!
|
|
77
|
+
|
|
78
|
+
# Each argument crosses a Ractor boundary, so each must be shareable.
|
|
79
|
+
# Integers and the frozen path are; `app` was just checked.
|
|
80
|
+
@app = app
|
|
81
|
+
@path = @socket_path.dup.freeze
|
|
82
|
+
@live = {}
|
|
83
|
+
@mutex = Mutex.new
|
|
84
|
+
@exit_port = Ractor::Port.new
|
|
85
|
+
@next_index = 0
|
|
86
|
+
@running = true
|
|
87
|
+
|
|
88
|
+
# In the main Ractor, before any Ractor starts. Hooks cannot run *inside* a
|
|
89
|
+
# Ractor at all -- they are blocks closing over the config file's scope,
|
|
90
|
+
# which is exactly what cannot cross a Ractor boundary -- so there is one
|
|
91
|
+
# boot hook per process here rather than one per unit of capacity. For the
|
|
92
|
+
# usual use, establishing a connection pool, per-process is what you want
|
|
93
|
+
# anyway.
|
|
94
|
+
@hooks.run(:on_worker_boot, @worker_id)
|
|
95
|
+
|
|
96
|
+
reaper = Thread.new { reap_loop }
|
|
97
|
+
@ractors.times { spawn_ractor }
|
|
98
|
+
|
|
99
|
+
@logger.puts "[puma-plus] worker #{@worker_id} pid=#{Process.pid} " \
|
|
100
|
+
"serving with #{@ractors} ractors"
|
|
101
|
+
|
|
102
|
+
# A Ractor cannot be killed from outside, so unlike the threaded worker
|
|
103
|
+
# there is no Thread#kill equivalent to drain with. The graceful path is
|
|
104
|
+
# GOAWAY from Go, which breaks each WorkerThread's read loop from the
|
|
105
|
+
# inside; TERM is the abrupt fallback and does drop in-flight requests.
|
|
106
|
+
trap("TERM") { @running = false }
|
|
107
|
+
trap("INT") { @running = false }
|
|
108
|
+
|
|
109
|
+
# The control connection is what makes autoscaling possible: without it
|
|
110
|
+
# Go can retire Ractors on its own (GOAWAY an idle conn) but has no way to
|
|
111
|
+
# ask for a new one, since only Ruby can call Ractor.new.
|
|
112
|
+
@control = ControlChannel.new(socket_path: @socket_path, logger: @logger).connect!
|
|
113
|
+
@control.run(self)
|
|
114
|
+
reaper.kill
|
|
115
|
+
ensure
|
|
116
|
+
@hooks.run(:on_worker_shutdown, @worker_id, fatal: false)
|
|
117
|
+
@control&.close
|
|
118
|
+
Warning[:experimental] = warning_was unless warning_was.nil?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# --- ControlChannel handler protocol ---
|
|
122
|
+
|
|
123
|
+
def running? = @running
|
|
124
|
+
|
|
125
|
+
# SET_SLOTS carries the desired Ractor count.
|
|
126
|
+
def set_slots(target) = adjust_ractors(target)
|
|
127
|
+
|
|
128
|
+
# Nothing to stop replacing: Ractors are never respawned on death here, and
|
|
129
|
+
# Go decides the count.
|
|
130
|
+
def quiesce = @logger.puts("[puma-plus] quiescing")
|
|
131
|
+
|
|
132
|
+
def shutdown(_grace_ms) = @running = false
|
|
133
|
+
|
|
134
|
+
# One process, so one pid. Go reads its RSS from /proc; a per-Ractor share
|
|
135
|
+
# is not a real quantity, because Ractors share a heap.
|
|
136
|
+
def heartbeat_kv
|
|
137
|
+
[["workers", live_count], ["worker_pids", Process.pid.to_s]]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Start one Ractor serving one connection.
|
|
143
|
+
#
|
|
144
|
+
# thread_index comes from a monotonic counter rather than the current size
|
|
145
|
+
# of the set, because a process that scaled down and back up would otherwise
|
|
146
|
+
# reuse an index still held by a Ractor that had not finished draining --
|
|
147
|
+
# and Go identifies a connection by (worker_id, thread_index).
|
|
148
|
+
def spawn_ractor
|
|
149
|
+
idx = @next_index
|
|
150
|
+
@next_index += 1
|
|
151
|
+
|
|
152
|
+
r = Ractor.new(@path, @app, @worker_id, idx, @exit_port, @ractors > 1,
|
|
153
|
+
name: "puma-plus #{@worker_id}/#{idx}") do |p, a, wid, i, port, mt|
|
|
154
|
+
# multithread because sibling Ractors call the app concurrently in this
|
|
155
|
+
# process; not multiprocess, since Ractor mode is a single process.
|
|
156
|
+
WorkerThread.new(socket_path: p, app: a, worker_id: wid, thread_index: i,
|
|
157
|
+
multithread: mt, multiprocess: false).run
|
|
158
|
+
ensure
|
|
159
|
+
# Reported from inside because a Ractor's exit cannot be polled from
|
|
160
|
+
# outside: there is no #alive?, and #join blocks. An ensure block also
|
|
161
|
+
# covers the Ractor dying of an exception, which a success-only signal
|
|
162
|
+
# would miss -- leaving the count permanently too high and the process
|
|
163
|
+
# unable to scale back up.
|
|
164
|
+
port.send(i)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
@mutex.synchronize { @live[idx] = r }
|
|
168
|
+
r
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Remove Ractors as they finish.
|
|
172
|
+
#
|
|
173
|
+
# A Ractor ends when Go sends GOAWAY to its connection and WorkerThread's
|
|
174
|
+
# read loop breaks, so scale-down is initiated entirely from Go and arrives
|
|
175
|
+
# here only as bookkeeping. Runs in its own thread because Port#receive
|
|
176
|
+
# blocks; it is an ordinary thread in the main Ractor, so it can share
|
|
177
|
+
# @live under a mutex like any other.
|
|
178
|
+
def reap_loop
|
|
179
|
+
while @running
|
|
180
|
+
idx = @exit_port.receive
|
|
181
|
+
@mutex.synchronize { @live.delete(idx) }
|
|
182
|
+
end
|
|
183
|
+
rescue Ractor::ClosedError, Ractor::Error
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def live_count = @mutex.synchronize { @live.size }
|
|
188
|
+
|
|
189
|
+
# SET_SLOTS carries the desired Ractor count.
|
|
190
|
+
#
|
|
191
|
+
# Only ever grows. Shrinking is Go's job and it has already happened by the
|
|
192
|
+
# time this arrives: Go drains an idle connection with GOAWAY and then sends
|
|
193
|
+
# the lower target, so acting on a shrink here would at best be redundant
|
|
194
|
+
# and at worst would race the drain. This is the one place the Ractor
|
|
195
|
+
# actuator is genuinely simpler than the forking one -- the shepherd has to
|
|
196
|
+
# pick a pid and TERM it, and has to distinguish that from a crash.
|
|
197
|
+
def adjust_ractors(target)
|
|
198
|
+
target = 1 if target.nil? || target < 1
|
|
199
|
+
|
|
200
|
+
spawned = 0
|
|
201
|
+
while live_count < target
|
|
202
|
+
spawn_ractor
|
|
203
|
+
spawned += 1
|
|
204
|
+
end
|
|
205
|
+
return if spawned.zero?
|
|
206
|
+
|
|
207
|
+
@logger.puts "[puma-plus] spawned #{spawned} ractor(s), now #{live_count}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# An app crosses into a Ractor only if it is deeply frozen. Modules and
|
|
211
|
+
# classes already qualify -- an app that is `MyApp` with `def self.call`
|
|
212
|
+
# needs nothing. A Rack::Builder middleware chain is a graph of ordinary
|
|
213
|
+
# objects and has to be deep-frozen, which is where most real apps refuse:
|
|
214
|
+
# anything memoizing into an ivar on first request, or holding a connection
|
|
215
|
+
# pool, or lazily building a route table, will raise here or (worse) at the
|
|
216
|
+
# first request. That is a genuine limitation of Ractor mode, not a bug.
|
|
217
|
+
def shareable!(app)
|
|
218
|
+
return app if Ractor.shareable?(app)
|
|
219
|
+
|
|
220
|
+
Ractor.make_shareable(app)
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
raise "#{@app_path} is not Ractor-shareable (#{e.class}: #{e.message}). " \
|
|
223
|
+
"Ractor mode requires an app that can be deep-frozen; run with " \
|
|
224
|
+
"--threads instead."
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|