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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: aa68ce5c2a9260719885681158aaef4faa2b8f70187232bb3b78d7d5fb0d7b9a
|
|
4
|
+
data.tar.gz: 14505256a7bae7411c669edfce2428a319cd2e7f5b4dd8b321e8909cb126dabe
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b012201b85d423d11882ab801193bacc46b7a0a6fc0a69680a6bc4a4ebec4b615fdb9b7d1e13a9a7187f950dba7a95296c99aa62774814cedae084878872349b
|
|
7
|
+
data.tar.gz: 5439a0c1e43df45dba7525509b54eeefbdf76def6d7e1fa30d765b82658dda6a59201647fe360fa35f4b70229b26d2612d9fb042425b65850cfe50ef599d8384
|
data/exe/puma-plus
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The puma-plus command.
|
|
5
|
+
#
|
|
6
|
+
# Reads configuration the way puma does -- a Ruby file, CLI flags overriding it
|
|
7
|
+
# -- and then execs the Go server. See PumaPlus::Launcher for why Ruby parses
|
|
8
|
+
# the config and why this execs rather than supervises.
|
|
9
|
+
|
|
10
|
+
require "puma_plus/launcher"
|
|
11
|
+
|
|
12
|
+
exit(PumaPlus::Launcher.new(ARGV).run || 0)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Entry point for a puma-plus Ruby worker process.
|
|
5
|
+
#
|
|
6
|
+
# Normally spawned by the Go server, which passes the worker socket path. Can be
|
|
7
|
+
# run by hand against `puma-plus -spawn=false` for debugging.
|
|
8
|
+
|
|
9
|
+
require "optparse"
|
|
10
|
+
require "puma_plus/worker"
|
|
11
|
+
require "puma_plus/ractor_worker"
|
|
12
|
+
require "puma_plus/shepherd"
|
|
13
|
+
|
|
14
|
+
# PUMA_PLUS_CONFIG is set by the `puma-plus` launcher before it execs the Go
|
|
15
|
+
# server, and inherited from there. Lifecycle hooks are blocks, so they cannot
|
|
16
|
+
# arrive as flags -- this process has to read the config file itself.
|
|
17
|
+
options = { threads: 5, worker_id: 0, workers: 0, ractors: 0,
|
|
18
|
+
config: ENV["PUMA_PLUS_CONFIG"] }
|
|
19
|
+
|
|
20
|
+
OptionParser.new do |opts|
|
|
21
|
+
opts.banner = "usage: puma-plus-worker --socket PATH --app PATH [--threads N]"
|
|
22
|
+
|
|
23
|
+
opts.on("--socket PATH", "unix socket to dial") { |v| options[:socket] = v }
|
|
24
|
+
opts.on("--app PATH", "Rack app (config.ru or .rb)") { |v| options[:app] = v }
|
|
25
|
+
opts.on("--threads N", Integer, "threads to run (default 5)") { |v| options[:threads] = v }
|
|
26
|
+
opts.on("--ractors N", Integer,
|
|
27
|
+
"serve from N Ractors instead of N threads (real parallelism; needs a "\
|
|
28
|
+
"deep-freezable app)") do |v|
|
|
29
|
+
options[:ractors] = v
|
|
30
|
+
end
|
|
31
|
+
opts.on("--config PATH", "config file to read lifecycle hooks from") { |v| options[:config] = v }
|
|
32
|
+
opts.on("--worker-id N", Integer, "worker index (default 0)") { |v| options[:worker_id] = v }
|
|
33
|
+
opts.on("--workers N", Integer,
|
|
34
|
+
"run a shepherd managing N worker processes (0 = single process, no shepherd)") do |v|
|
|
35
|
+
options[:workers] = v
|
|
36
|
+
end
|
|
37
|
+
opts.on("-h", "--help") { puts opts; exit }
|
|
38
|
+
end.parse!
|
|
39
|
+
|
|
40
|
+
abort "--socket is required" unless options[:socket]
|
|
41
|
+
abort "--app is required" unless options[:app]
|
|
42
|
+
abort "app not found: #{options[:app]}" unless File.exist?(options[:app])
|
|
43
|
+
|
|
44
|
+
# With --workers 0 this process serves directly, which keeps the Phase 1 debug
|
|
45
|
+
# path working. With --workers N it becomes a shepherd: it preloads the app,
|
|
46
|
+
# forks N children so they inherit a warm heap, and takes scaling commands from
|
|
47
|
+
# the Go server over the control connection.
|
|
48
|
+
if options[:ractors].positive? && options[:workers].positive?
|
|
49
|
+
abort "--ractors and --workers cannot be combined: the shepherd forks processes "\
|
|
50
|
+
"that run threads, and Ractor mode is one process. Use one or the other."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if options[:ractors].positive?
|
|
54
|
+
# Ractors are units of real parallelism, so N ractors in one process is the
|
|
55
|
+
# in-process analogue of N forked workers -- without the fork dead time.
|
|
56
|
+
PumaPlus::RactorWorker.new(
|
|
57
|
+
socket_path: options[:socket],
|
|
58
|
+
app_path: options[:app],
|
|
59
|
+
ractors: options[:ractors],
|
|
60
|
+
worker_id: options[:worker_id],
|
|
61
|
+
config_path: options[:config]
|
|
62
|
+
).run
|
|
63
|
+
elsif options[:workers].positive?
|
|
64
|
+
PumaPlus::Shepherd.new(
|
|
65
|
+
socket_path: options[:socket],
|
|
66
|
+
app_path: options[:app],
|
|
67
|
+
workers: options[:workers],
|
|
68
|
+
threads: options[:threads],
|
|
69
|
+
config_path: options[:config]
|
|
70
|
+
).run
|
|
71
|
+
else
|
|
72
|
+
PumaPlus::Worker.new(
|
|
73
|
+
socket_path: options[:socket],
|
|
74
|
+
app_path: options[:app],
|
|
75
|
+
threads: options[:threads],
|
|
76
|
+
worker_id: options[:worker_id],
|
|
77
|
+
config_path: options[:config]
|
|
78
|
+
).run
|
|
79
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mkmf"
|
|
4
|
+
|
|
5
|
+
# The whole extension is one API. Without it there is nothing to build, so bail
|
|
6
|
+
# out gracefully rather than emitting a Makefile that produces a useless .so --
|
|
7
|
+
# the Ruby wrapper falls back to the cheaper heuristics.
|
|
8
|
+
unless have_func("rb_internal_thread_add_event_hook", "ruby/thread.h")
|
|
9
|
+
warn "puma-plus: rb_internal_thread_add_event_hook is unavailable " \
|
|
10
|
+
"(needs Ruby >= 3.0); GVL instrumentation will be disabled"
|
|
11
|
+
File.write("Makefile", "install:\n\t@true\nclean:\n\t@true\nall:\n\t@true\n")
|
|
12
|
+
exit 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
have_func("clock_gettime")
|
|
16
|
+
create_makefile("puma_plus_gvl")
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* GVL wait instrumentation for puma-plus.
|
|
3
|
+
*
|
|
4
|
+
* Measures how long each thread spends RUNNABLE BUT UNABLE TO RUN because
|
|
5
|
+
* another thread in the same process holds the GVL. Ruby exposes no other way
|
|
6
|
+
* to see this, and without it the scaling controller cannot tell two very
|
|
7
|
+
* different situations apart:
|
|
8
|
+
*
|
|
9
|
+
* - a thread blocked on a database socket -> more threads WILL help
|
|
10
|
+
* - a thread queued behind the GVL -> more threads will NOT help,
|
|
11
|
+
* and may make latency worse
|
|
12
|
+
*
|
|
13
|
+
* Both look identical to every cheaper heuristic, because a thread waiting for
|
|
14
|
+
* the GVL accrues neither CPU time nor IO wait: it is not on CPU, and it is not
|
|
15
|
+
* blocked on a file descriptor. It is simply queued. That is the blind spot
|
|
16
|
+
* this closes.
|
|
17
|
+
*
|
|
18
|
+
* Implementation constraint: the callback fires BETWEEN GVL states, so it must
|
|
19
|
+
* not touch the Ruby VM at all -- no allocation, no VALUEs, no calling back into
|
|
20
|
+
* Ruby. It does exactly two things: read a monotonic clock and add to a counter.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
#include <ruby.h>
|
|
24
|
+
#include <ruby/thread.h>
|
|
25
|
+
#include <stdatomic.h>
|
|
26
|
+
#include <time.h>
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* Ruby 3.2's rb_internal_thread_event_data_t is `void` -- it carries no thread
|
|
30
|
+
* identity yet. That is fine, because the callback runs ON the thread the event
|
|
31
|
+
* concerns, so a thread-local is exactly the right place to stash the timestamp.
|
|
32
|
+
*/
|
|
33
|
+
static _Thread_local uint64_t tl_ready_at = 0;
|
|
34
|
+
static _Thread_local uint64_t tl_wait_ns = 0;
|
|
35
|
+
static _Thread_local uint64_t tl_waits = 0;
|
|
36
|
+
|
|
37
|
+
/* Process-wide totals, for the per-second heartbeat. */
|
|
38
|
+
static _Atomic uint64_t g_wait_ns = 0;
|
|
39
|
+
static _Atomic uint64_t g_waits = 0;
|
|
40
|
+
|
|
41
|
+
static rb_internal_thread_event_hook_t *g_hook = NULL;
|
|
42
|
+
|
|
43
|
+
static inline uint64_t monotonic_ns(void) {
|
|
44
|
+
struct timespec ts;
|
|
45
|
+
clock_gettime(CLOCK_MONOTONIC, &ts);
|
|
46
|
+
return (uint64_t)ts.tv_sec * 1000000000ULL + (uint64_t)ts.tv_nsec;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static void on_thread_event(rb_event_flag_t event,
|
|
50
|
+
const rb_internal_thread_event_data_t *data,
|
|
51
|
+
void *user_data) {
|
|
52
|
+
(void)data;
|
|
53
|
+
(void)user_data;
|
|
54
|
+
|
|
55
|
+
switch (event) {
|
|
56
|
+
case RUBY_INTERNAL_THREAD_EVENT_READY:
|
|
57
|
+
/* This thread now wants the GVL. Start the clock. */
|
|
58
|
+
tl_ready_at = monotonic_ns();
|
|
59
|
+
break;
|
|
60
|
+
|
|
61
|
+
case RUBY_INTERNAL_THREAD_EVENT_RESUMED: {
|
|
62
|
+
/* It got the GVL. The gap is time it was runnable but blocked. */
|
|
63
|
+
if (tl_ready_at != 0) {
|
|
64
|
+
uint64_t waited = monotonic_ns() - tl_ready_at;
|
|
65
|
+
tl_ready_at = 0;
|
|
66
|
+
tl_wait_ns += waited;
|
|
67
|
+
tl_waits += 1;
|
|
68
|
+
atomic_fetch_add_explicit(&g_wait_ns, waited, memory_order_relaxed);
|
|
69
|
+
atomic_fetch_add_explicit(&g_waits, 1, memory_order_relaxed);
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
case RUBY_INTERNAL_THREAD_EVENT_EXITED:
|
|
75
|
+
tl_ready_at = 0;
|
|
76
|
+
break;
|
|
77
|
+
|
|
78
|
+
default:
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static VALUE gvl_start(VALUE self) {
|
|
84
|
+
if (g_hook != NULL) return Qfalse;
|
|
85
|
+
|
|
86
|
+
g_hook = rb_internal_thread_add_event_hook(
|
|
87
|
+
on_thread_event,
|
|
88
|
+
RUBY_INTERNAL_THREAD_EVENT_READY |
|
|
89
|
+
RUBY_INTERNAL_THREAD_EVENT_RESUMED |
|
|
90
|
+
RUBY_INTERNAL_THREAD_EVENT_EXITED,
|
|
91
|
+
NULL);
|
|
92
|
+
return g_hook != NULL ? Qtrue : Qfalse;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static VALUE gvl_stop(VALUE self) {
|
|
96
|
+
if (g_hook == NULL) return Qfalse;
|
|
97
|
+
bool removed = rb_internal_thread_remove_event_hook(g_hook);
|
|
98
|
+
g_hook = NULL;
|
|
99
|
+
return removed ? Qtrue : Qfalse;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static VALUE gvl_running_p(VALUE self) {
|
|
103
|
+
return g_hook != NULL ? Qtrue : Qfalse;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* Nanoseconds this process has spent waiting for the GVL, all threads. */
|
|
107
|
+
static VALUE gvl_wait_ns(VALUE self) {
|
|
108
|
+
return ULL2NUM(atomic_load_explicit(&g_wait_ns, memory_order_relaxed));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static VALUE gvl_waits(VALUE self) {
|
|
112
|
+
return ULL2NUM(atomic_load_explicit(&g_waits, memory_order_relaxed));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
* Nanoseconds THIS thread has spent waiting for the GVL.
|
|
117
|
+
*
|
|
118
|
+
* Read around app.call, the delta is directly comparable to that request's
|
|
119
|
+
* service time: gvl_wait/service is the fraction of the request spent queued
|
|
120
|
+
* behind other Ruby threads rather than doing work.
|
|
121
|
+
*/
|
|
122
|
+
static VALUE gvl_thread_wait_ns(VALUE self) {
|
|
123
|
+
return ULL2NUM(tl_wait_ns);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static VALUE gvl_thread_waits(VALUE self) {
|
|
127
|
+
return ULL2NUM(tl_waits);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
void Init_puma_plus_gvl(void) {
|
|
131
|
+
/* Callable from a Ractor. Ruby assumes a C extension is Ractor-unsafe until
|
|
132
|
+
* told otherwise, so without this every method here raises
|
|
133
|
+
* Ractor::UnsafeError from a Ractor worker -- and RactorWorker reads
|
|
134
|
+
* thread_wait_ns on the request path.
|
|
135
|
+
*
|
|
136
|
+
* The claim is true by construction rather than by inspection: every piece
|
|
137
|
+
* of state this extension owns is either _Thread_local (tl_ready_at,
|
|
138
|
+
* tl_wait_ns, tl_waits) or _Atomic (g_wait_ns, g_waits, g_running). It
|
|
139
|
+
* allocates nothing, touches no VALUE, and calls into no VM function from
|
|
140
|
+
* the hook -- which it already had to guarantee, since the hook runs on
|
|
141
|
+
* threads that do not hold the GVL.
|
|
142
|
+
*
|
|
143
|
+
* What the numbers MEAN does change under Ractors, though the code does
|
|
144
|
+
* not. Ractors hold separate locks, so sibling Ractors no longer contend;
|
|
145
|
+
* a high fraction here now indicates contention between threads within one
|
|
146
|
+
* Ractor rather than across the process. */
|
|
147
|
+
rb_ext_ractor_safe(true);
|
|
148
|
+
|
|
149
|
+
VALUE mPumaPlus = rb_define_module("PumaPlus");
|
|
150
|
+
VALUE mGVL = rb_define_module_under(mPumaPlus, "GVL");
|
|
151
|
+
|
|
152
|
+
rb_define_singleton_method(mGVL, "native_start!", gvl_start, 0);
|
|
153
|
+
rb_define_singleton_method(mGVL, "native_stop!", gvl_stop, 0);
|
|
154
|
+
rb_define_singleton_method(mGVL, "running?", gvl_running_p, 0);
|
|
155
|
+
rb_define_singleton_method(mGVL, "wait_ns", gvl_wait_ns, 0);
|
|
156
|
+
rb_define_singleton_method(mGVL, "waits", gvl_waits, 0);
|
|
157
|
+
rb_define_singleton_method(mGVL, "thread_wait_ns", gvl_thread_wait_ns, 0);
|
|
158
|
+
rb_define_singleton_method(mGVL, "thread_waits", gvl_thread_waits, 0);
|
|
159
|
+
rb_define_const(mGVL, "NATIVE", Qtrue);
|
|
160
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PumaPlus
|
|
4
|
+
# Address parsing shared by the config DSL and the command line.
|
|
5
|
+
#
|
|
6
|
+
# Its own file because both the launcher and the worker need it, and the
|
|
7
|
+
# worker loads the DSL without the launcher -- it reads the config only for
|
|
8
|
+
# lifecycle hooks. Having the DSL reach back into Launcher made that
|
|
9
|
+
# combination raise NameError on any config using activate_control_app, which
|
|
10
|
+
# no test caught because every launcher test loads the launcher.
|
|
11
|
+
module Address
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Normalize whatever someone typed for an address into "host:port".
|
|
15
|
+
#
|
|
16
|
+
# Accepts "tcp://127.0.0.1:9293", "127.0.0.1:9293", ":9293" and "9293",
|
|
17
|
+
# because all four are things people type and three of them are not URLs.
|
|
18
|
+
# URI.parse alone rejects the bare forms with a message about RFC 3986 that
|
|
19
|
+
# tells you nothing about what to do instead.
|
|
20
|
+
def host_port(value, default_port, what)
|
|
21
|
+
s = value.to_s.strip.sub(%r{\A[a-zA-Z][a-zA-Z0-9+.-]*://}, "")
|
|
22
|
+
case s
|
|
23
|
+
when /\A\[([^\]]+)\]:(\d+)\z/ then "[#{Regexp.last_match(1)}]:#{Regexp.last_match(2)}"
|
|
24
|
+
when /\A(.*):(\d+)\z/
|
|
25
|
+
host = Regexp.last_match(1)
|
|
26
|
+
"#{host.empty? ? '0.0.0.0' : host}:#{Regexp.last_match(2)}"
|
|
27
|
+
when /\A\d+\z/ then "0.0.0.0:#{s}"
|
|
28
|
+
when "" then raise(ConfigError, "#{what}: expected an address like 127.0.0.1:#{default_port}")
|
|
29
|
+
else "#{s}:#{default_port}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PumaPlus
|
|
4
|
+
# Loading a Rack app from a path.
|
|
5
|
+
#
|
|
6
|
+
# Extracted from Worker so the thread-backed and Ractor-backed workers load
|
|
7
|
+
# apps identically -- if they diverged, a benchmark comparing them would be
|
|
8
|
+
# comparing app loading as much as concurrency.
|
|
9
|
+
module AppLoader
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Load a Rack app from a .ru or a plain .rb.
|
|
13
|
+
#
|
|
14
|
+
# The .ru path uses Rack::Builder when the rack gem is available, and falls
|
|
15
|
+
# back to a minimal builder otherwise. The spike must be runnable before
|
|
16
|
+
# `gem install rack`, and a Rack app is only an object responding to #call.
|
|
17
|
+
def load(app_path)
|
|
18
|
+
source = File.read(app_path)
|
|
19
|
+
|
|
20
|
+
if app_path.end_with?(".ru")
|
|
21
|
+
begin
|
|
22
|
+
require "rack"
|
|
23
|
+
return Rack::Builder.new_from_string(source, app_path)
|
|
24
|
+
rescue LoadError
|
|
25
|
+
return MiniBuilder.build(source, app_path)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
require File.expand_path(app_path)
|
|
30
|
+
unless defined?(::App) && ::App.respond_to?(:call)
|
|
31
|
+
raise "#{app_path} must define an App constant responding to #call"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
::App
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A stand-in for Rack::Builder supporting just `run` and `use`, so an example
|
|
38
|
+
# config.ru works with no gems installed. Not a Rack::Builder replacement --
|
|
39
|
+
# real apps should install rack.
|
|
40
|
+
module MiniBuilder
|
|
41
|
+
def self.build(source, path)
|
|
42
|
+
builder = new_builder
|
|
43
|
+
builder.instance_eval(source, path, 1)
|
|
44
|
+
builder.to_app
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.new_builder
|
|
48
|
+
Object.new.tap do |o|
|
|
49
|
+
o.instance_variable_set(:@run, nil)
|
|
50
|
+
o.instance_variable_set(:@use, [])
|
|
51
|
+
def o.run(app) = @run = app
|
|
52
|
+
def o.use(middleware, *args, &blk) = @use << [middleware, args, blk]
|
|
53
|
+
def o.map(*) = raise(NotImplementedError, "map requires the rack gem")
|
|
54
|
+
|
|
55
|
+
def o.to_app
|
|
56
|
+
raise "config.ru never called run()" unless @run
|
|
57
|
+
|
|
58
|
+
@use.reverse.inject(@run) do |app, (mw, args, blk)|
|
|
59
|
+
mw.new(app, *args, &blk)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "puma_plus/address"
|
|
4
|
+
|
|
5
|
+
module PumaPlus
|
|
6
|
+
# Three-layer option lookup: command line beats config file beats default.
|
|
7
|
+
#
|
|
8
|
+
# Same precedence as puma's UserFileDefaultOptions, and for the same reason --
|
|
9
|
+
# a config file is checked in and shared, a command line is what you type to
|
|
10
|
+
# override it right now. Kept as three separate hashes rather than one merged
|
|
11
|
+
# one so `puma-plus --port 3000` still wins over a `port 9292` in the file no
|
|
12
|
+
# matter which was read first.
|
|
13
|
+
class Options
|
|
14
|
+
def initialize(defaults = {})
|
|
15
|
+
@default = defaults
|
|
16
|
+
@file = {}
|
|
17
|
+
@user = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
attr_reader :default, :file, :user
|
|
21
|
+
|
|
22
|
+
def [](key)
|
|
23
|
+
return @user[key] if @user.key?(key)
|
|
24
|
+
return @file[key] if @file.key?(key)
|
|
25
|
+
|
|
26
|
+
@default[key]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def key?(key) = @user.key?(key) || @file.key?(key) || @default.key?(key)
|
|
30
|
+
|
|
31
|
+
# Where a value came from. Used by `--dry-run` so a surprising setting can be
|
|
32
|
+
# traced to the line that set it.
|
|
33
|
+
def origin(key)
|
|
34
|
+
return :cli if @user.key?(key)
|
|
35
|
+
return :config if @file.key?(key)
|
|
36
|
+
return :default if @default.key?(key)
|
|
37
|
+
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h
|
|
42
|
+
@default.merge(@file).merge(@user)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The config-file DSL, evaluated against a plain Ruby file the way puma
|
|
47
|
+
# evaluates config/puma.rb.
|
|
48
|
+
#
|
|
49
|
+
# A Ruby file rather than YAML because the interesting things are not values.
|
|
50
|
+
# Lifecycle hooks are blocks, worker counts are routinely computed from
|
|
51
|
+
# `Etc.nprocessors` or an env var, and a config that can branch on
|
|
52
|
+
# `environment` is the reason anyone tolerates a config file at all.
|
|
53
|
+
#
|
|
54
|
+
# Names follow puma wherever the concept exists, so a config can be ported by
|
|
55
|
+
# deleting the lines that no longer apply rather than rewritten. Where puma has
|
|
56
|
+
# no equivalent -- autoscaling, Ractors, HTTP/2 and /3 -- the names are ours.
|
|
57
|
+
class ConfigFile
|
|
58
|
+
# Options a puma config sets that puma-plus has no equivalent for, mapped to
|
|
59
|
+
# why. Accepted and ignored with a warning rather than raising: a config
|
|
60
|
+
# ported from puma should boot, and then tell you what it dropped.
|
|
61
|
+
IGNORED = {
|
|
62
|
+
preload_app!: "puma-plus always preloads; the shepherd loads the app once and forks",
|
|
63
|
+
prune_bundler: "the Go server is not a gem, so there is no bundler to prune",
|
|
64
|
+
queue_requests: "requests are always queued, in Go -- that is the whole design",
|
|
65
|
+
wait_for_less_busy_worker: "Go dispatches to an idle worker by construction",
|
|
66
|
+
fork_worker: "not implemented",
|
|
67
|
+
nakayoshi_fork: "not implemented",
|
|
68
|
+
set_remote_address: "Go builds REMOTE_ADDR when it constructs the env"
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
def initialize(options, path: nil)
|
|
72
|
+
@options = options
|
|
73
|
+
@path = path
|
|
74
|
+
@hooks = Hash.new { |h, k| h[k] = [] }
|
|
75
|
+
@warnings = []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
attr_reader :hooks, :warnings
|
|
79
|
+
|
|
80
|
+
def self.load(path, options)
|
|
81
|
+
dsl = new(options, path: path)
|
|
82
|
+
dsl.instance_eval(File.read(path), path, 1)
|
|
83
|
+
dsl
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# --- listeners ------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
# bind "tcp://0.0.0.0:9292"
|
|
89
|
+
# bind "ssl://0.0.0.0:9443"
|
|
90
|
+
#
|
|
91
|
+
# Unix sockets are deliberately unsupported for the *client* listener: the
|
|
92
|
+
# unix socket in this design is how workers dial in, and letting a config
|
|
93
|
+
# point a public listener at one invites confusing the two.
|
|
94
|
+
def bind(url)
|
|
95
|
+
uri = begin
|
|
96
|
+
URI.parse(url)
|
|
97
|
+
rescue URI::Error => e
|
|
98
|
+
raise ConfigError, "bind #{url.inspect}: #{e.message}"
|
|
99
|
+
end
|
|
100
|
+
case uri.scheme
|
|
101
|
+
when "tcp", "http"
|
|
102
|
+
set :listen, "#{uri.host}:#{uri.port || 9292}"
|
|
103
|
+
when "ssl", "https"
|
|
104
|
+
set :listen_tls, "#{uri.host}:#{uri.port || 9443}"
|
|
105
|
+
apply_ssl_params(URI.decode_www_form(uri.query.to_s).to_h, url)
|
|
106
|
+
when "unix"
|
|
107
|
+
raise ConfigError, "bind #{url.inspect}: unix sockets are not supported for " \
|
|
108
|
+
"the HTTP listener (the unix socket is how workers dial in)"
|
|
109
|
+
else
|
|
110
|
+
raise ConfigError, "bind #{url.inspect}: unknown scheme #{uri.scheme.inspect}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def port(port, host = nil)
|
|
115
|
+
set :listen, "#{host || '0.0.0.0'}:#{Integer(port)}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# HTTP/2 cleartext on the main listener. Nothing in puma to mirror.
|
|
119
|
+
def h2c(enabled = true) = set(:h2c, !!enabled)
|
|
120
|
+
|
|
121
|
+
# HTTP/3 needs its own address because QUIC is UDP.
|
|
122
|
+
def http3(addr) = set(:listen_h3, addr.to_s)
|
|
123
|
+
|
|
124
|
+
def tls_hosts(*names) = set(:tls_hosts, names.flatten.join(","))
|
|
125
|
+
|
|
126
|
+
# ssl_bind "0.0.0.0", 9443, cert: "...", key: "..."
|
|
127
|
+
#
|
|
128
|
+
# puma's signature, so an existing ssl_bind line ports unchanged. The
|
|
129
|
+
# equivalent query-string form on `bind` works too, since both end up here.
|
|
130
|
+
def ssl_bind(host, port, opts = {})
|
|
131
|
+
set :listen_tls, "#{host}:#{Integer(port)}"
|
|
132
|
+
apply_ssl_params(opts.transform_keys(&:to_s), "ssl_bind")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Certificate and key paths for the TLS and HTTP/3 listeners.
|
|
136
|
+
def tls_cert(cert, key) = (set(:tls_cert, expand(cert)); set(:tls_key, expand(key)))
|
|
137
|
+
|
|
138
|
+
# --- concurrency ----------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def workers(count) = set(:workers, Integer(count))
|
|
141
|
+
|
|
142
|
+
# threads min, max
|
|
143
|
+
#
|
|
144
|
+
# puma-plus has no thread pool to grow and shrink: a thread is a connection
|
|
145
|
+
# to Go, dialed at boot and held. So only the max is meaningful, and a
|
|
146
|
+
# config that asks for a range gets told which number was used rather than
|
|
147
|
+
# silently having one picked.
|
|
148
|
+
def threads(min, max = min)
|
|
149
|
+
min = Integer(min)
|
|
150
|
+
max = Integer(max)
|
|
151
|
+
raise ConfigError, "threads: min (#{min}) must be <= max (#{max})" if min > max
|
|
152
|
+
|
|
153
|
+
if min != max
|
|
154
|
+
@warnings << "threads #{min},#{max}: puma-plus holds a fixed number of " \
|
|
155
|
+
"worker connections, so #{max} is used and #{min} ignored"
|
|
156
|
+
end
|
|
157
|
+
set :threads, max
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Serve from N Ractors in one process instead of threads across forks.
|
|
161
|
+
def ractors(count) = set(:ractors, Integer(count))
|
|
162
|
+
|
|
163
|
+
def max_connections(n) = set(:max_conns, Integer(n))
|
|
164
|
+
|
|
165
|
+
# --- autoscaling ----------------------------------------------------
|
|
166
|
+
|
|
167
|
+
# autoscale min: 1, max: 8, target_queue_p95: "25ms"
|
|
168
|
+
def autoscale(min: nil, max: nil, target_queue_p95: nil, enabled: true)
|
|
169
|
+
set :autoscale, !!enabled
|
|
170
|
+
set :min_workers, Integer(min) if min
|
|
171
|
+
set :max_workers, Integer(max) if max
|
|
172
|
+
set :target_queue_p95, duration(target_queue_p95, "target_queue_p95") if target_queue_p95
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def mem_limit(mb) = set(:mem_limit_mb, Integer(mb))
|
|
176
|
+
def decision_log(path) = set(:decision_log, path.to_s)
|
|
177
|
+
def acquire_timeout(d) = set(:acquire_timeout, duration(d, "acquire_timeout"))
|
|
178
|
+
|
|
179
|
+
# --- app ------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def rackup(path) = set(:app, path.to_s)
|
|
182
|
+
def environment(env) = set(:environment, env.to_s)
|
|
183
|
+
def directory(dir) = set(:directory, File.expand_path(dir.to_s))
|
|
184
|
+
def tag(name) = set(:tag, name.to_s)
|
|
185
|
+
def pidfile(path) = set(:pidfile, path.to_s)
|
|
186
|
+
|
|
187
|
+
# Where /stats, /metrics and /health are served.
|
|
188
|
+
def activate_control_app(url = "tcp://127.0.0.1:9293", _opts = {})
|
|
189
|
+
set :control, Address.host_port(url, 9293, "activate_control_app")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def websockets(enabled = true) = set(:websockets, !!enabled)
|
|
193
|
+
def webtransport(enabled = true) = set(:webtransport, !!enabled)
|
|
194
|
+
def debug_headers(enabled = true) = set(:debug_headers, !!enabled)
|
|
195
|
+
|
|
196
|
+
# --- lifecycle hooks ------------------------------------------------
|
|
197
|
+
#
|
|
198
|
+
# Blocks, so they cannot be translated into flags for the Go process. The
|
|
199
|
+
# worker re-reads this same config file and runs them itself; the launcher
|
|
200
|
+
# only records that they exist so it can pass the path along.
|
|
201
|
+
|
|
202
|
+
def before_fork(&blk) = @hooks[:before_fork] << blk
|
|
203
|
+
def on_worker_boot(&blk) = @hooks[:on_worker_boot] << blk
|
|
204
|
+
def on_worker_shutdown(&blk) = @hooks[:on_worker_shutdown] << blk
|
|
205
|
+
|
|
206
|
+
# --- accepted and ignored -------------------------------------------
|
|
207
|
+
|
|
208
|
+
IGNORED.each do |name, why|
|
|
209
|
+
define_method(name) do |*_args, &_blk|
|
|
210
|
+
@warnings << "#{name}: ignored -- #{why}"
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
# Shared by `bind "ssl://..."` and `ssl_bind`, so the two forms cannot drift.
|
|
218
|
+
#
|
|
219
|
+
# Only cert and key are honoured. The rest of puma's SSL options configure a
|
|
220
|
+
# TLS stack this server does not have, and quietly accepting `verify_mode`
|
|
221
|
+
# would imply client-certificate verification that is not happening.
|
|
222
|
+
def apply_ssl_params(params, where)
|
|
223
|
+
cert = params["cert"] || params["cert_pem"]
|
|
224
|
+
key = params["key"] || params["key_pem"]
|
|
225
|
+
|
|
226
|
+
if cert && key
|
|
227
|
+
set :tls_cert, expand(cert)
|
|
228
|
+
set :tls_key, expand(key)
|
|
229
|
+
elsif cert || key
|
|
230
|
+
raise ConfigError, "#{where}: cert and key must be given together " \
|
|
231
|
+
"(got only #{cert ? 'cert' : 'key'})"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
ignored = params.keys - %w[cert key cert_pem key_pem]
|
|
235
|
+
return if ignored.empty?
|
|
236
|
+
|
|
237
|
+
@warnings << "#{where}: ignored SSL options #{ignored.join(', ')} -- only " \
|
|
238
|
+
"cert and key are supported"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def expand(path) = File.expand_path(path.to_s)
|
|
242
|
+
|
|
243
|
+
def set(key, value)
|
|
244
|
+
@options.file[key] = value
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Accepts "25ms", "1s", 0.25 (seconds), or an Integer of seconds, and always
|
|
248
|
+
# emits a Go-parseable duration string.
|
|
249
|
+
def duration(value, what)
|
|
250
|
+
case value
|
|
251
|
+
when Numeric then "#{(value * 1000).round}ms"
|
|
252
|
+
when String
|
|
253
|
+
unless value =~ /\A\d+(\.\d+)?(ns|us|ms|s|m|h)\z/
|
|
254
|
+
raise ConfigError, "#{what}: #{value.inspect} is not a duration like \"25ms\" or \"1s\""
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
value
|
|
258
|
+
else
|
|
259
|
+
raise ConfigError, "#{what}: expected a duration, got #{value.class}"
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
class ConfigError < StandardError; end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
require "uri"
|