volucer-worker 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/LICENSE +21 -0
- data/README.md +442 -0
- data/lib/volucer/grpc.rb +83 -0
- data/lib/volucer/message.rb +152 -0
- data/lib/volucer/protocol.rb +244 -0
- data/lib/volucer/rack.rb +196 -0
- data/lib/volucer/rpc.rb +622 -0
- data/lib/volucer/services.rb +529 -0
- data/lib/volucer/supervisor.rb +299 -0
- data/lib/volucer/version.rb +15 -0
- data/lib/volucer/worker.rb +836 -0
- data/lib/volucer.rb +111 -0
- metadata +58 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A prefork supervisor for volucer's `attach` backend.
|
|
4
|
+
#
|
|
5
|
+
# The other half of `attach`. volucer only *dials* a relay in this mode -- it starts no
|
|
6
|
+
# process at all -- because the fork has to happen in the application's own process: a PHP
|
|
7
|
+
# process cannot pass a file descriptor to another process (`ext/sockets` has no
|
|
8
|
+
# `SCM_RIGHTS`), so the side that owns the listening socket has to be the side that owns the
|
|
9
|
+
# fork. The same is true of the Ruby SDK, for a different reason: `fork` in a process that
|
|
10
|
+
# has already started threads is a question with no good answer, and a library cannot know
|
|
11
|
+
# when that is. So the fork belongs in the supervisor, and the supervisor belongs to the
|
|
12
|
+
# application.
|
|
13
|
+
#
|
|
14
|
+
# require 'volucer'
|
|
15
|
+
# require_relative 'config/environment' # boot the app first
|
|
16
|
+
#
|
|
17
|
+
# Volucer::Supervisor.serve(
|
|
18
|
+
# 'unix:/run/app/workers.sock',
|
|
19
|
+
# Volucer::Rack.handler(Rails.application),
|
|
20
|
+
# children: 16
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# **Unix only.** `Process.fork` does not exist on Windows (and JRuby has it disabled by
|
|
24
|
+
# default), and `serve` raises there rather than silently degrading to one process. On
|
|
25
|
+
# Windows use `kind = "worker"` (volucer spawns the processes) or `kind = "upstream"`.
|
|
26
|
+
#
|
|
27
|
+
# **Boot before the fork.** Whatever the handler closes over is inherited by every child --
|
|
28
|
+
# a Rails application, a connection-pool *configuration*, loaded classes -- so it has to be
|
|
29
|
+
# built before calling `serve`. What must *not* be built before the fork is an open
|
|
30
|
+
# connection: every child would inherit it and they would take turns reading from it. Rails
|
|
31
|
+
# is safe here because `ActiveRecord` checks connections out lazily, per request, and
|
|
32
|
+
# `ActionDispatch::Executor` returns them to the pool afterwards.
|
|
33
|
+
#
|
|
34
|
+
# **`Process.detach` is not optional.** A child that exits stays a zombie until its parent
|
|
35
|
+
# waits for it, so a supervisor that only accepts and forks grows one per connection --
|
|
36
|
+
# measured at exactly 10 zombies for 10 connections on the PHP side.
|
|
37
|
+
require 'socket'
|
|
38
|
+
|
|
39
|
+
module Volucer
|
|
40
|
+
module Supervisor
|
|
41
|
+
# The `unix:` prefix volucer's configuration uses for a path relay.
|
|
42
|
+
UNIX_PREFIX = 'unix:'
|
|
43
|
+
|
|
44
|
+
# The relay to listen on: the argument, else the environment, else nil.
|
|
45
|
+
#
|
|
46
|
+
# nil rather than an exception, because whether a missing relay is an error depends on the
|
|
47
|
+
# caller. `serve` turns it into a startup failure naming both places; a test or an embedding
|
|
48
|
+
# application may want to decide for itself.
|
|
49
|
+
#
|
|
50
|
+
# The trimming rule is `Volucer.socket_address`'s, deliberately: an empty value is what a shell
|
|
51
|
+
# produces from `VOLUCER_RELAY_SOCKET=`, and reporting it as an empty address would send the
|
|
52
|
+
# operator looking for a typo in a file that never mentions the key.
|
|
53
|
+
def self.relay(relay = nil)
|
|
54
|
+
unless relay.nil?
|
|
55
|
+
relay = relay.strip
|
|
56
|
+
return relay unless relay.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
address = ENV.fetch(RELAY_ENV, nil)
|
|
60
|
+
return nil if address.nil?
|
|
61
|
+
|
|
62
|
+
address = address.strip
|
|
63
|
+
address.empty? ? nil : address
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# How long `accept` waits before the master reaps children and looks again.
|
|
67
|
+
#
|
|
68
|
+
# It costs nothing when traffic is arriving -- `accept` returns the instant a connection
|
|
69
|
+
# is pending -- and bounds how long the master sleeps when none is, which is exactly when
|
|
70
|
+
# it needs to be reaping.
|
|
71
|
+
ACCEPT_TIMEOUT = 0.5
|
|
72
|
+
|
|
73
|
+
# How long to wait when every child slot is busy.
|
|
74
|
+
SLOT_BACKOFF = 0.005
|
|
75
|
+
|
|
76
|
+
# The filesystem path of a `unix:` relay, or nil for a TCP one.
|
|
77
|
+
def self.relay_path(relay)
|
|
78
|
+
relay = relay.strip
|
|
79
|
+
return nil unless relay.start_with?(UNIX_PREFIX)
|
|
80
|
+
|
|
81
|
+
relay[UNIX_PREFIX.length..]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Bind and listen on a relay, removing a stale socket file first.
|
|
85
|
+
#
|
|
86
|
+
# A unix socket path is a *file*, and binding one that already exists fails with
|
|
87
|
+
# `EADDRINUSE` -- so a supervisor restarted after a crash could never come up. Unlinking
|
|
88
|
+
# first is what every Unix server does, and it is safe here because this process is meant
|
|
89
|
+
# to be the only listener on that path: a live supervisor would hold the socket, and the
|
|
90
|
+
# bind would fail rather than two processes sharing one relay.
|
|
91
|
+
def self.listen(relay)
|
|
92
|
+
path = relay_path(relay)
|
|
93
|
+
if path.nil?
|
|
94
|
+
host, _, port = relay.rpartition(':')
|
|
95
|
+
listener = TCPServer.new(host.empty? ? '127.0.0.1' : host, Integer(port))
|
|
96
|
+
listener.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
|
|
97
|
+
return listener
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
begin
|
|
101
|
+
File.unlink(path)
|
|
102
|
+
rescue Errno::ENOENT
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
server = UNIXServer.new(path)
|
|
107
|
+
server
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Listen on `relay` and fork one child per accepted connection.
|
|
111
|
+
#
|
|
112
|
+
# `handler` is the same block `Worker#run` takes, so a Rack application is
|
|
113
|
+
# `Volucer::Rack.handler(application)`.
|
|
114
|
+
#
|
|
115
|
+
# `children` caps how many children may run at once, not a queue depth. `nil` — the
|
|
116
|
+
# default — means **no cap**, which is what an `attach` deployment wants: volucer dials the
|
|
117
|
+
# relay exactly `workers` times at startup and holds one connection per slot, so the live
|
|
118
|
+
# children are already bounded by that number. A cap is only useful to reserve memory for
|
|
119
|
+
# *other* processes on the host, and a cap **below** `workers` is not a queue -- it is a
|
|
120
|
+
# startup failure, because the surplus dials sit unaccepted in the listen backlog while the
|
|
121
|
+
# pool waits for a handshake no child is there to send.
|
|
122
|
+
#
|
|
123
|
+
# Each child serves exactly one connection and exits, which is what makes recycling cheap
|
|
124
|
+
# and the assignment exact: volucer closes a slot, the child sees end-of-file and exits,
|
|
125
|
+
# and this loop forks a replacement for the next connection.
|
|
126
|
+
#
|
|
127
|
+
# The master runs until it is asked to stop. `TERM`/`INT` stop it accepting, close the
|
|
128
|
+
# listener, remove the socket file, and wait briefly for the children already serving --
|
|
129
|
+
# the graceful half of what a service manager expects. A timeout on `accept` is what makes
|
|
130
|
+
# the stop flag observable without a self-pipe.
|
|
131
|
+
# `relay` may be nil, in which case `Volucer::Supervisor.relay` reads `VOLUCER_RELAY_SOCKET`.
|
|
132
|
+
# That is the form a deployment should use: the server reads the same variable for its
|
|
133
|
+
# `relay`, so the address is written once instead of in two files that nothing keeps in sync.
|
|
134
|
+
# A value passed here still wins, so a script that names its own socket keeps meaning what it
|
|
135
|
+
# says.
|
|
136
|
+
def self.serve(relay, handler, children: nil)
|
|
137
|
+
unless Process.respond_to?(:fork)
|
|
138
|
+
raise NotImplementedError,
|
|
139
|
+
'Volucer::Supervisor needs Process.fork, which this platform does not have; ' \
|
|
140
|
+
'use a `worker` or `upstream` backend here, or run the supervisor on Unix'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
unless children.nil? || children >= 1
|
|
144
|
+
raise ArgumentError, "children must be nil (no cap) or at least 1, got #{children}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Resolved before the fork, so a missing address is one startup error rather than one per
|
|
148
|
+
# child -- and so `listen` is never handed nil.
|
|
149
|
+
address = self.relay(relay)
|
|
150
|
+
if address.nil?
|
|
151
|
+
raise ArgumentError,
|
|
152
|
+
'no relay to listen on: pass one to Volucer::Supervisor.serve, or set ' \
|
|
153
|
+
"#{RELAY_ENV} -- the same variable the server reads for its `relay`, " \
|
|
154
|
+
'e.g. "unix:/run/app/workers.sock"'
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
limit = children
|
|
158
|
+
|
|
159
|
+
listener = listen(address)
|
|
160
|
+
|
|
161
|
+
stopping = false
|
|
162
|
+
install_signals { stopping = true }
|
|
163
|
+
|
|
164
|
+
announce(address, limit)
|
|
165
|
+
|
|
166
|
+
live = {}
|
|
167
|
+
begin
|
|
168
|
+
until stopping
|
|
169
|
+
reap(live)
|
|
170
|
+
|
|
171
|
+
if !limit.nil? && live.length >= limit
|
|
172
|
+
sleep SLOT_BACKOFF
|
|
173
|
+
next
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
connection = accept_with_timeout(listener)
|
|
177
|
+
next if connection.nil?
|
|
178
|
+
|
|
179
|
+
child = fork do
|
|
180
|
+
# --- the child ---
|
|
181
|
+
# The master's bookkeeping is meaningless here and must not be inherited as if it
|
|
182
|
+
# were this process's own children.
|
|
183
|
+
live = nil
|
|
184
|
+
listener.close
|
|
185
|
+
serve_one(connection, handler)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The *master* must close its copy. An inherited descriptor is not reference counted
|
|
189
|
+
# by the protocol: leaving it open here means the child's end-of-file never happens,
|
|
190
|
+
# so a recycled slot would hang instead of reconnecting.
|
|
191
|
+
connection.close
|
|
192
|
+
# Tracked rather than `Process.detach`ed: detaching reaps behind this loop's back, so
|
|
193
|
+
# the live count would never fall and the supervisor would stop accepting after
|
|
194
|
+
# `children` connections. `reap` is what collects them, from the top of the loop.
|
|
195
|
+
live[child] = true
|
|
196
|
+
end
|
|
197
|
+
ensure
|
|
198
|
+
listener.close
|
|
199
|
+
path = relay_path(address)
|
|
200
|
+
if path
|
|
201
|
+
begin
|
|
202
|
+
File.unlink(path)
|
|
203
|
+
rescue Errno::ENOENT
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# One connection, served on a connection the supervisor opened.
|
|
211
|
+
#
|
|
212
|
+
# No wrapper is needed, unlike Python: a `TCPSocket` already responds to `read` and
|
|
213
|
+
# `write`, which is what the SDK calls.
|
|
214
|
+
#
|
|
215
|
+
# `exit!` rather than `exit`, and that is deliberate twice over: normal shutdown would run
|
|
216
|
+
# the master's `at_exit` handlers once per child, and it would flush buffers the master had
|
|
217
|
+
# already written, so every child would write them again -- the classic duplicated-output
|
|
218
|
+
# bug of a forked server.
|
|
219
|
+
def self.serve_one(connection, handler)
|
|
220
|
+
code = 0
|
|
221
|
+
begin
|
|
222
|
+
Worker.create(connection, connection).run(&handler)
|
|
223
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
224
|
+
begin
|
|
225
|
+
$stderr.write("[volucer] worker exited: #{e.class}: #{e.message}\n")
|
|
226
|
+
$stderr.flush
|
|
227
|
+
rescue StandardError
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
code = 1
|
|
231
|
+
ensure
|
|
232
|
+
begin
|
|
233
|
+
connection.close
|
|
234
|
+
rescue StandardError
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
exit!(code)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Accept one connection, returning nil when the timeout expires.
|
|
242
|
+
#
|
|
243
|
+
# `IO.select` rather than `accept_nonblock` in a retry loop: one call says whether a
|
|
244
|
+
# connection is pending, and the timeout is the only reason this exists -- it is what lets
|
|
245
|
+
# the loop notice its stop flag while no traffic is arriving.
|
|
246
|
+
def self.accept_with_timeout(listener)
|
|
247
|
+
ready = IO.select([listener], nil, nil, ACCEPT_TIMEOUT)
|
|
248
|
+
return nil if ready.nil?
|
|
249
|
+
|
|
250
|
+
listener.accept_nonblock(exception: false).then do |accepted|
|
|
251
|
+
accepted.is_a?(Symbol) ? nil : accepted
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Reap every child that has exited.
|
|
256
|
+
#
|
|
257
|
+
# `Process.waitpid(-1, WNOHANG)` in a loop is the shape that cannot block in the middle of
|
|
258
|
+
# the accept loop. Nothing calls `Process.detach` here on purpose: a detached child is
|
|
259
|
+
# reaped by a background thread, so the live count would never fall and the supervisor
|
|
260
|
+
# would stop accepting after `children` connections. One reaper, one place.
|
|
261
|
+
def self.reap(live)
|
|
262
|
+
loop do
|
|
263
|
+
pid = Process.waitpid(-1, Process::WNOHANG)
|
|
264
|
+
break if pid.nil?
|
|
265
|
+
|
|
266
|
+
live.delete(pid)
|
|
267
|
+
end
|
|
268
|
+
rescue Errno::ECHILD
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Install the shutdown handler, where the platform allows it.
|
|
273
|
+
#
|
|
274
|
+
# A library started from a non-main thread can still trap signals in Ruby, but the write
|
|
275
|
+
# to a plain local from a trap handler is only safe because it is a boolean assignment;
|
|
276
|
+
# a raise from a handler must not escape, so an unsupported signal is tolerated.
|
|
277
|
+
def self.install_signals(&block)
|
|
278
|
+
%w[TERM INT].each do |name|
|
|
279
|
+
Signal.trap(name, &block)
|
|
280
|
+
rescue ArgumentError, StandardError
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The one readiness line a deployment needs.
|
|
286
|
+
#
|
|
287
|
+
# A unit file or a container healthcheck can wait for this line rather than guessing, and
|
|
288
|
+
# it goes to stderr because stdout is the protocol channel.
|
|
289
|
+
def self.announce(relay, children)
|
|
290
|
+
cap = children.nil? ? 'no child cap' : "#{children} children at most"
|
|
291
|
+
$stderr.write("[volucer] listening on #{relay} (#{cap})\n")
|
|
292
|
+
$stderr.flush
|
|
293
|
+
rescue StandardError
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
private_class_method :serve_one, :accept_with_timeout, :reap, :install_signals, :announce
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Volucer
|
|
4
|
+
# The gem version, in a file of its own.
|
|
5
|
+
#
|
|
6
|
+
# **The gemspec must not require `volucer` itself.** That file rewrites `$stdout` so an
|
|
7
|
+
# application's `puts` cannot corrupt the worker channel, and a gemspec is evaluated by
|
|
8
|
+
# bundler on *every* `bundle` command — so requiring the library from the gemspec gave
|
|
9
|
+
# ordinary commands like `bundle exec rake test` a hijacked stdout. On Ruby 4, rake calls
|
|
10
|
+
# `$stdout.set_encoding`, `GuardedStdout` does not implement it, and the release died with
|
|
11
|
+
# `undefined method 'set_encoding' for an instance of Volucer::GuardedStdout`.
|
|
12
|
+
#
|
|
13
|
+
# Reading one constant is all a gemspec should ever need from its own library.
|
|
14
|
+
VERSION = '0.1.0'
|
|
15
|
+
end
|