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.
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "etc"
5
+ require "puma_plus/wire"
6
+
7
+ module PumaPlus
8
+ # The control connection to the Go server, shared by every kind of worker
9
+ # supervisor.
10
+ #
11
+ # Extracted because Shepherd (which forks processes) and RactorWorker (which
12
+ # spawns Ractors) had byte-identical copies of connect, command loop,
13
+ # heartbeat and RSS reading. The only genuinely different parts are what
14
+ # SET_SLOTS means and what goes in the heartbeat -- which is exactly what the
15
+ # handler protocol below covers. Duplicating the rest meant a protocol change
16
+ # had to be made in two files, and nothing would have failed if it were made
17
+ # in one.
18
+ #
19
+ # A supervisor is the handler. It must respond to:
20
+ #
21
+ # running? -- keep looping?
22
+ # set_slots(n) -- SET_SLOTS arrived; n is the desired capacity
23
+ # quiesce -- stop replacing capacity that dies
24
+ # shutdown(ms) -- stop, with this much grace
25
+ # heartbeat_kv -- extra [key, value] pairs for WORKER_STATUS
26
+ #
27
+ # and may respond to:
28
+ #
29
+ # on_idle -- called each pass, whether or not a frame arrived, for
30
+ # work that cannot wait on the socket (the shepherd reaps
31
+ # dead children here)
32
+ class ControlChannel
33
+ # Long enough to be cheap, short enough that signal flags and child deaths
34
+ # are noticed promptly rather than only when Go happens to send something.
35
+ SELECT_TIMEOUT = 0.25
36
+
37
+ def initialize(socket_path:, logger: $stderr)
38
+ @socket_path = socket_path
39
+ @logger = logger
40
+ # Guards the socket. The heartbeat thread writes WORKER_STATUS while the
41
+ # command loop writes PONG, and without this the two can interleave
42
+ # mid-frame and hand Go a corrupt stream -- a race that existed in both
43
+ # copies of this code and had simply never been hit, since PING is only
44
+ # sent when a worker looks suspect.
45
+ @write_mu = Mutex.new
46
+ end
47
+
48
+ def connect!
49
+ @conn = UNIXSocket.new(@socket_path)
50
+ @conn.sync = true
51
+ write(Wire::HELLO, Wire.encode_kv([
52
+ ["version", Wire::VERSION],
53
+ ["role", "control"],
54
+ ["pid", Process.pid]
55
+ ]))
56
+ self
57
+ end
58
+
59
+ # Serve commands until the connection closes or the handler stops running.
60
+ def run(handler)
61
+ heartbeat = Thread.new { heartbeat_loop(handler) }
62
+ command_loop(handler)
63
+ ensure
64
+ heartbeat&.kill
65
+ end
66
+
67
+ def close
68
+ @conn&.close
69
+ rescue IOError
70
+ nil
71
+ end
72
+
73
+ # Advisory only. Go reads /proc itself for the memory guard rather than
74
+ # trusting a number reported by a process it may need to kill.
75
+ def self.rss_kb
76
+ File.read("/proc/self/statm").split[1].to_i * (Etc.sysconf(Etc::SC_PAGESIZE) / 1024)
77
+ rescue StandardError
78
+ 0
79
+ end
80
+
81
+ private
82
+
83
+ def log(msg)
84
+ @logger.puts "[puma-plus control] #{msg}"
85
+ @logger.flush if @logger.respond_to?(:flush)
86
+ end
87
+
88
+ def write(type, payload = nil)
89
+ @write_mu.synchronize { @conn.write Wire.frame(type, payload) }
90
+ end
91
+
92
+ def command_loop(handler)
93
+ while handler.running?
94
+ handler.on_idle if handler.respond_to?(:on_idle)
95
+
96
+ ready = IO.select([@conn], nil, nil, SELECT_TIMEOUT)
97
+ next unless ready
98
+
99
+ begin
100
+ type, payload = Wire.read_frame(@conn)
101
+ rescue Wire::Closed
102
+ log "connection closed by server"
103
+ break
104
+ end
105
+
106
+ case type
107
+ when Wire::SET_SLOTS
108
+ handler.set_slots(payload.unpack1("N"))
109
+ when Wire::QUIESCE
110
+ handler.quiesce
111
+ when Wire::SHUTDOWN
112
+ handler.shutdown(payload.unpack1("N") || 5000)
113
+ when Wire::PING
114
+ write(Wire::PONG)
115
+ else
116
+ log "unexpected frame #{Wire.type_name(type)}"
117
+ end
118
+ end
119
+ end
120
+
121
+ def heartbeat_loop(handler)
122
+ seq = 0
123
+ while handler.running?
124
+ sleep 1
125
+ seq += 1
126
+ begin
127
+ write(Wire::WORKER_STATUS, Wire.encode_kv(
128
+ [["seq", seq], ["pid", Process.pid]] +
129
+ handler.heartbeat_kv +
130
+ [["rss_hint", self.class.rss_kb * 1024]]
131
+ ))
132
+ rescue IOError, Errno::EPIPE
133
+ break
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PumaPlus
4
+ # GVL wait instrumentation.
5
+ #
6
+ # Measures how long threads spend runnable but unable to run because another
7
+ # thread in the same process holds the GVL. That number decides the hardest
8
+ # question the scaling controller asks: add a thread, or fork a process?
9
+ #
10
+ # Without it, the cheap heuristic is io_fraction = 1 - cpu/service, and it is
11
+ # not merely imprecise but backwards in the case that matters. A thread queued
12
+ # behind the GVL accrues no CPU time, so a purely CPU-bound request whose wall
13
+ # time is mostly queueing reads as "60% IO-bound" -- and the controller adds
14
+ # threads to a process where more threads make latency worse. Measured
15
+ # directly, the same request reports a 0.60 GVL fraction and the controller
16
+ # forks instead.
17
+ #
18
+ # The native extension is optional. Without it every method here returns 0 and
19
+ # the controller falls back to io_fraction plus gain probing, which is what it
20
+ # did before this existed.
21
+ module GVL
22
+ @native = begin
23
+ require "puma_plus_gvl"
24
+ true
25
+ rescue LoadError
26
+ false
27
+ end
28
+
29
+ # Fallbacks, defined only when the extension is absent so the real ones win.
30
+ unless @native
31
+ NATIVE = false
32
+
33
+ class << self
34
+ def native_start! = false
35
+ def native_stop! = false
36
+ def running? = false
37
+ def wait_ns = 0
38
+ def waits = 0
39
+ def thread_wait_ns = 0
40
+ def thread_waits = 0
41
+ end
42
+ end
43
+
44
+ class << self
45
+ # Is the native extension available?
46
+ def native? = const_defined?(:NATIVE) && const_get(:NATIVE)
47
+
48
+ # Begin measuring. Called once per worker process.
49
+ #
50
+ # Returns false when unavailable, which is not an error: the controller
51
+ # simply uses its cheaper signals.
52
+ def start!
53
+ return false unless native?
54
+
55
+ native_start!
56
+ end
57
+
58
+ def stop!
59
+ return false unless native?
60
+
61
+ native_stop!
62
+ end
63
+
64
+ # What fraction of an interval was spent waiting for the GVL.
65
+ #
66
+ # Above ~0.3 means this process is GVL-bound: its threads are queueing on
67
+ # each other rather than doing work, and adding more of them will deepen
68
+ # the queue rather than raise throughput.
69
+ def fraction(wait_ns, service_ns)
70
+ return 0.0 if service_ns.nil? || service_ns <= 0
71
+
72
+ wait_ns.to_f / service_ns
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puma_plus/config_file"
4
+
5
+ module PumaPlus
6
+ # Lifecycle hooks from the config file.
7
+ #
8
+ # These are the one part of the configuration that cannot be translated into
9
+ # flags for the Go server, because they are blocks. So the config file is read
10
+ # twice: once by the `puma-plus` launcher, which turns settings into flags and
11
+ # then execs Go, and again here by the Ruby process that actually runs the
12
+ # application, which is the only process where a hook can meaningfully run.
13
+ #
14
+ # Reading it twice means any top-level side effect in the config file happens
15
+ # twice. That is worth knowing and is why config files should declare rather
16
+ # than do -- the same advice puma's own docs give, for the same reason.
17
+ #
18
+ # Semantics follow puma exactly (verified against puma/lib/puma/cluster.rb:438
19
+ # and cluster/worker.rb:58,151):
20
+ #
21
+ # before_fork once in the shepherd, before ANY worker is forked
22
+ # on_worker_boot(idx) in each worker after fork, before it serves
23
+ # on_worker_shutdown in each worker, before it exits
24
+ #
25
+ # The timing of on_worker_boot is unusually clean here. A worker becomes
26
+ # visible to Go by dialing in, so running boot hooks before that dial means Go
27
+ # cannot dispatch a request to a worker whose hooks have not finished. In puma
28
+ # the equivalent guarantee needs the master to track a booted state; here it
29
+ # falls out of the connection being the readiness signal.
30
+ class Hooks
31
+ EMPTY = {}.freeze
32
+
33
+ def self.load(path, logger: $stderr)
34
+ return new(EMPTY, logger: logger) unless path && File.exist?(path)
35
+
36
+ # A throwaway Options: every non-hook directive in the file still runs and
37
+ # still sets values, and all of them are discarded. The launcher already
38
+ # turned those into flags, and honouring them here would let a worker
39
+ # disagree with the server about its own configuration.
40
+ dsl = ConfigFile.load(path, Options.new({}))
41
+ new(dsl.hooks, logger: logger, path: path)
42
+ rescue StandardError => e
43
+ # A config file that raises when read for hooks would otherwise take down
44
+ # a worker with a stack trace and no indication that hooks were the cause.
45
+ raise ConfigError, "loading hooks from #{path}: #{e.class}: #{e.message}"
46
+ end
47
+
48
+ def initialize(hooks, logger: $stderr, path: nil)
49
+ @hooks = hooks || EMPTY
50
+ @logger = logger
51
+ @path = path
52
+ end
53
+
54
+ attr_reader :path
55
+
56
+ def any?(name) = !(@hooks[name].nil? || @hooks[name].empty?)
57
+
58
+ def names = @hooks.keys.select { |k| any?(k) }
59
+
60
+ # Run every block registered for +name+, in declaration order.
61
+ #
62
+ # fatal: is the difference between "this process cannot do its job" and
63
+ # "this process is leaving anyway". A failed on_worker_boot means the worker
64
+ # would serve requests without whatever the hook was supposed to establish
65
+ # -- a database connection, most often -- so it dies loudly instead of
66
+ # serving wrongly. A failed shutdown hook is logged and the shutdown
67
+ # continues, because refusing to exit helps nobody.
68
+ def run(name, *args, fatal: true)
69
+ blocks = @hooks[name]
70
+ return if blocks.nil? || blocks.empty?
71
+
72
+ blocks.each_with_index do |blk, i|
73
+ blk.call(*args)
74
+ rescue StandardError, ScriptError => e
75
+ where = "#{name} hook #{i + 1}/#{blocks.size}#{@path ? " from #{@path}" : ''}"
76
+ if fatal
77
+ @logger.puts "[puma-plus] #{where} failed: #{e.class}: #{e.message}"
78
+ Array(e.backtrace).first(8).each { |l| @logger.puts "[puma-plus] #{l}" }
79
+ raise HookError, "#{where} failed: #{e.class}: #{e.message}"
80
+ end
81
+
82
+ @logger.puts "[puma-plus] #{where} failed (continuing): #{e.class}: #{e.message}"
83
+ end
84
+ end
85
+ end
86
+
87
+ class HookError < StandardError; end
88
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "puma_plus/wire"
5
+
6
+ module PumaPlus
7
+ module Input
8
+ # An empty rack.input.
9
+ #
10
+ # Transcribed from puma/lib/puma/null_io.rb rather than depended upon: the
11
+ # class is a handful of methods, and the gem takes no dependencies.
12
+ class NullIO
13
+ def gets = nil
14
+ def each; end
15
+ def close; end
16
+ def size = 0
17
+ def eof? = true
18
+ def rewind = 0
19
+ def sync = true
20
+ def string = ""
21
+ def closed? = false
22
+ def read_ns = 0
23
+
24
+ def sync=(value)
25
+ value
26
+ end
27
+
28
+ def read(count = nil, buffer = nil)
29
+ result = count.nil? || count.zero? ? "" : nil
30
+ if buffer
31
+ buffer.clear
32
+ buffer << result if result
33
+ return count.nil? || count.zero? ? buffer : nil
34
+ end
35
+ result
36
+ end
37
+
38
+ def readpartial(_count, buffer = nil)
39
+ raise EOFError if buffer.nil?
40
+
41
+ buffer.clear
42
+ raise EOFError
43
+ end
44
+ end
45
+
46
+ # Frozen, which NullIO can afford because it holds no state at all. Two
47
+ # reasons, and the first stands on its own: a rack.input shared across every
48
+ # bodyless request must not be mutable, or one request could scribble on
49
+ # another's. The second is that a frozen stateless object is Ractor-shareable,
50
+ # and this constant is on the request path -- unfrozen, it raises
51
+ # IsolationError the first time a Ractor worker serves a GET.
52
+ EMPTY = NullIO.new.freeze
53
+
54
+ # A rack.input backed by BODY_CHUNK frames pulled from the worker connection
55
+ # on demand.
56
+ #
57
+ # Deliberately NOT rewindable. Rack 3 dropped the rewind requirement, and the
58
+ # payoff is that an arbitrarily large or chunked upload never touches a
59
+ # tempfile in either process. Compare puma, which decodes chunked bodies in
60
+ # Ruby and spools every one of them to a Tempfile
61
+ # (puma/lib/puma/client.rb:600), then rewrites CONTENT_LENGTH to the decoded
62
+ # size.
63
+ #
64
+ # Time spent blocked here is accumulated into #read_ns and reported in
65
+ # RESP_END, so the controller can subtract it from service time. Without
66
+ # that, a slow uploader would look exactly like an application that got
67
+ # slower, and the scaler would add capacity to fix a problem capacity cannot
68
+ # fix.
69
+ class FrameStream
70
+ # Sentinel returned by #read at EOF when a length was requested.
71
+ def initialize(conn, prefix = nil)
72
+ @conn = conn
73
+ @buf = +""
74
+ @buf << prefix if prefix && !prefix.empty?
75
+ @eof = false
76
+ @closed = false
77
+ @read_ns = 0
78
+ end
79
+
80
+ # Nanoseconds blocked reading body frames.
81
+ attr_reader :read_ns
82
+
83
+ def read(length = nil, outbuf = nil)
84
+ if length.nil?
85
+ fill_all
86
+ data = @buf
87
+ @buf = +""
88
+ outbuf ? outbuf.replace(data) : data
89
+ else
90
+ return outbuf ? outbuf.clear : +"" if length.zero?
91
+
92
+ fill_until(length)
93
+ if @buf.empty?
94
+ outbuf&.clear
95
+ return nil # EOF, per IO#read semantics
96
+ end
97
+ data = @buf.byteslice(0, length)
98
+ @buf = @buf.byteslice(length, @buf.bytesize - length) || +""
99
+ outbuf ? outbuf.replace(data) : data
100
+ end
101
+ end
102
+
103
+ def gets(sep = $INPUT_RECORD_SEPARATOR || "\n")
104
+ loop do
105
+ if (i = @buf.index(sep))
106
+ line = @buf.byteslice(0, i + sep.bytesize)
107
+ @buf = @buf.byteslice(i + sep.bytesize, @buf.bytesize - i - sep.bytesize) || +""
108
+ return line
109
+ end
110
+ break if @eof
111
+
112
+ pull or break
113
+ end
114
+
115
+ return nil if @buf.empty?
116
+
117
+ line = @buf
118
+ @buf = +""
119
+ line
120
+ end
121
+
122
+ def each
123
+ while (line = gets)
124
+ yield line
125
+ end
126
+ end
127
+
128
+ def eof?
129
+ @eof && @buf.empty?
130
+ end
131
+
132
+ def close
133
+ @closed = true
134
+ end
135
+
136
+ def closed? = @closed
137
+
138
+ # rewind is part of the Rack 2 SPEC but not Rack 3's. Raise rather than
139
+ # silently returning 0, so an app that depends on it fails loudly instead
140
+ # of quietly reading an empty body.
141
+ def rewind
142
+ raise Errno::ESPIPE, "puma-plus streams large request bodies; rack.input is not rewindable"
143
+ end
144
+
145
+ # Consume and discard anything the application did not read.
146
+ #
147
+ # Required before RESP_END: Go writes body frames concurrently with the
148
+ # application running, so if the app returns early (a 413, an auth
149
+ # rejection, a HEAD-like handler) the unread frames are still in flight.
150
+ # Leaving them would desynchronise the connection and the next request on
151
+ # it would read a body chunk where it expected a REQUEST.
152
+ def drain
153
+ pull until @eof
154
+ @buf = +""
155
+ nil
156
+ end
157
+
158
+ private
159
+
160
+ def fill_all
161
+ pull until @eof
162
+ end
163
+
164
+ def fill_until(length)
165
+ pull while @buf.bytesize < length && !@eof
166
+ end
167
+
168
+ # Read one more frame from the connection. Returns false at end of body.
169
+ def pull
170
+ return false if @eof
171
+
172
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
173
+ type, payload = Wire.read_frame(@conn)
174
+ @read_ns += Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - t0
175
+
176
+ case type
177
+ when Wire::BODY_CHUNK
178
+ @buf << payload
179
+ true
180
+ when Wire::BODY_END
181
+ # Trailers ride on BODY_END. Nothing consumes them yet; decoding here
182
+ # keeps the frame accounted for and gives a place to surface them.
183
+ @trailers, = Wire.decode_kv(payload) unless payload.empty?
184
+ @eof = true
185
+ false
186
+ else
187
+ @eof = true
188
+ raise Wire::Truncated,
189
+ "expected BODY_CHUNK or BODY_END during body read, got #{Wire.type_name(type)}"
190
+ end
191
+ rescue Wire::Closed
192
+ @eof = true
193
+ false
194
+ end
195
+ end
196
+
197
+ # Build a rack.input for a decoded REQUEST.
198
+ #
199
+ # NONE -> the shared frozen NullIO.
200
+ # INLINE -> a StringIO over a byteslice of the frame payload. Rewindable for
201
+ # free, and covers essentially every form post and JSON API.
202
+ # STREAM -> FrameStream, pulling BODY_CHUNK frames on demand.
203
+ def self.for(mode, body, conn = nil)
204
+ case mode
205
+ when Wire::BODY_NONE then EMPTY
206
+ when Wire::BODY_INLINE then StringIO.new(body)
207
+ when Wire::BODY_STREAM
208
+ raise ArgumentError, "STREAM body requires a connection" unless conn
209
+
210
+ FrameStream.new(conn, body)
211
+ else
212
+ raise ArgumentError, "unknown body_mode #{mode}"
213
+ end
214
+ end
215
+ end
216
+ end