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.
@@ -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