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,836 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The worker loop: handshake, serve, and the exchanges that run beside it.
|
|
4
|
+
#
|
|
5
|
+
# The shape is the PHP and Python SDKs', because the three implement one specification: a
|
|
6
|
+
# handshake, a read loop that dispatches on the frame type, and a response that is a head,
|
|
7
|
+
# zero or more body chunks, then a terminal frame.
|
|
8
|
+
#
|
|
9
|
+
# Two things here are Ruby's own answer rather than a transliteration:
|
|
10
|
+
#
|
|
11
|
+
# **Output.** The protocol uses the child's stdout as its channel. PHP rebinds `echo` with
|
|
12
|
+
# an output buffer and the Python SDK rebinds `sys.stdout`; Ruby's answer is that `$stdout`
|
|
13
|
+
# is reassigned to `$stderr` when this file is loaded, and the real protocol stream is kept
|
|
14
|
+
# in a private reference. `puts` therefore still reaches a human (through stderr, which the
|
|
15
|
+
# supervisor drains), but it cannot land inside a frame.
|
|
16
|
+
#
|
|
17
|
+
# **RSS.** The protocol's `peak_memory_bytes` drives recycling on `memory_limit_mb`, and it
|
|
18
|
+
# is meant *per request*. Ruby has no exact per-request answer either: `GC.stat` reports
|
|
19
|
+
# heap slots rather than bytes of resident memory, so this SDK reports the process's RSS as
|
|
20
|
+
# the platform gives it. That is cumulative and cannot fall, so a peak that once exceeded
|
|
21
|
+
# the limit recycles the worker after every request — which the README states, because it
|
|
22
|
+
# changes how a deployment should configure `memory_limit_mb`.
|
|
23
|
+
|
|
24
|
+
require 'socket'
|
|
25
|
+
|
|
26
|
+
require_relative 'protocol'
|
|
27
|
+
require_relative 'message'
|
|
28
|
+
|
|
29
|
+
module Volucer
|
|
30
|
+
# The environment variable naming a socket to connect to instead of using stdio.
|
|
31
|
+
#
|
|
32
|
+
# Set by the server on a platform where a pipe is not the transport — Windows named
|
|
33
|
+
# pipes — so a worker that supports it is reachable there. Absent, empty or whitespace
|
|
34
|
+
# means stdio, because `VOLUCER_WORKER_SOCKET=` is what a shell produces from a stray
|
|
35
|
+
# space and connecting to it would be a confusing failure rather than the obvious one.
|
|
36
|
+
SOCKET_ENV = 'VOLUCER_WORKER_SOCKET'
|
|
37
|
+
|
|
38
|
+
# The prefix volucer's configuration uses for a unix socket address.
|
|
39
|
+
#
|
|
40
|
+
# The same string `Volucer::Supervisor::UNIX_PREFIX` holds, and deliberately a separate
|
|
41
|
+
# constant: one side dials and the other listens, and either moving without the other is
|
|
42
|
+
# a bug worth being able to see.
|
|
43
|
+
UNIX_PREFIX = 'unix:'
|
|
44
|
+
|
|
45
|
+
# The environment variable naming the relay an `attach` backend dials.
|
|
46
|
+
#
|
|
47
|
+
# The counterpart of `SOCKET_ENV`, and for the same purpose: one address both halves of a
|
|
48
|
+
# deployment must agree on, written once. The difference is direction. `SOCKET_ENV` is set *by*
|
|
49
|
+
# the server, on a child it spawned, so a supervisor cannot use it -- there is no spawned child,
|
|
50
|
+
# and the value there would be whatever an enclosing environment happened to leave. This one is
|
|
51
|
+
# set by the *operator*, in the unit file or the container environment, and both the server's
|
|
52
|
+
# `relay` and `Volucer::Supervisor.serve` read it.
|
|
53
|
+
#
|
|
54
|
+
# Absent, empty or whitespace means unset, for the same reason `SOCKET_ENV` does.
|
|
55
|
+
RELAY_ENV = 'VOLUCER_RELAY_SOCKET'
|
|
56
|
+
|
|
57
|
+
# The package this SDK is, reported in `runtime`.
|
|
58
|
+
PACKAGE = 'volucer-worker-ruby'
|
|
59
|
+
|
|
60
|
+
# The socket the server asked to be reached at, or nil for stdio.
|
|
61
|
+
def self.socket_address
|
|
62
|
+
address = ENV.fetch(SOCKET_ENV, nil)
|
|
63
|
+
return nil if address.nil?
|
|
64
|
+
|
|
65
|
+
address = address.strip
|
|
66
|
+
address.empty? ? nil : address
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The filesystem path of a `unix:` address, or nil for a TCP one.
|
|
70
|
+
#
|
|
71
|
+
# The same convention volucer's configuration uses for `external.socket`, `attach.relay`
|
|
72
|
+
# and a worker's `socket` key, so this reads the rule rather than inventing a fourth
|
|
73
|
+
# spelling. A loopback TCP address is `host:port` and has no prefix, which is why the
|
|
74
|
+
# absence of one is the whole test.
|
|
75
|
+
def self.unix_path(address)
|
|
76
|
+
return nil unless address.start_with?(UNIX_PREFIX)
|
|
77
|
+
|
|
78
|
+
address[UNIX_PREFIX.length..]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Identify this worker to the server: the SDK and the build it is running.
|
|
82
|
+
#
|
|
83
|
+
# The build is Ruby's version rather than a gem version, because a worker is usually
|
|
84
|
+
# vendored rather than installed and a version constant would be one more thing to keep
|
|
85
|
+
# true by hand. `runtime` is a free-form string the server only logs.
|
|
86
|
+
def self.runtime
|
|
87
|
+
"#{PACKAGE}/#{RUBY_VERSION}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The worker side of the channel.
|
|
91
|
+
class Worker
|
|
92
|
+
attr_reader :last_call_was_known
|
|
93
|
+
|
|
94
|
+
# Build a worker on explicit streams, defaulting to stdio.
|
|
95
|
+
#
|
|
96
|
+
# The protocol streams are taken from the real `$stdout` *before* it is rebound, which
|
|
97
|
+
# is why this is a class method rather than a constructor call an application makes
|
|
98
|
+
# after the load-time rebinding has happened.
|
|
99
|
+
def self.create(input = nil, output = nil, error = nil)
|
|
100
|
+
input ||= $stdin
|
|
101
|
+
output ||= defined?(Volucer::CHANNEL_OUT) ? Volucer::CHANNEL_OUT : $stdout
|
|
102
|
+
error ||= $stderr
|
|
103
|
+
input.binmode if input.respond_to?(:binmode)
|
|
104
|
+
output.binmode if output.respond_to?(:binmode)
|
|
105
|
+
new(input, output, error)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Build a worker, using the socket transport when the server named one.
|
|
109
|
+
#
|
|
110
|
+
# This is what a worker file should call. With `VOLUCER_WORKER_SOCKET` set the protocol
|
|
111
|
+
# runs over a loopback socket; unset, over stdin/stdout exactly as `create` does.
|
|
112
|
+
#
|
|
113
|
+
# The option exists for one measured reason. On Windows, PHP's stream layer takes ~8 ms
|
|
114
|
+
# median to return from a blocking read on a *pipe* while the same read on a *socket*
|
|
115
|
+
# returns in ~0.09 ms (plan hazard 46). Ruby's own pipe cost differs, but the option is
|
|
116
|
+
# the server's and a worker that ignored it would present as a startup timeout.
|
|
117
|
+
#
|
|
118
|
+
# A worker that cannot reach the address exits non-zero rather than falling back to
|
|
119
|
+
# stdio: the server is waiting for a connection, so a silent fallback would hang.
|
|
120
|
+
def self.connect
|
|
121
|
+
address = Volucer.socket_address
|
|
122
|
+
return create if address.nil?
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
path = Volucer.unix_path(address)
|
|
126
|
+
socket =
|
|
127
|
+
if path
|
|
128
|
+
UNIXSocket.new(path)
|
|
129
|
+
else
|
|
130
|
+
host, _, port = address.rpartition(':')
|
|
131
|
+
Socket.tcp(host.empty? ? '127.0.0.1' : host, Integer(port), connect_timeout: 10)
|
|
132
|
+
end
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
# The address is the server's, so a failure here is a deployment problem and it has
|
|
135
|
+
# to be legible on stderr — which, on the socket transport, is the only channel the
|
|
136
|
+
# server sees.
|
|
137
|
+
$stderr.write("worker: cannot reach #{address} (#{SOCKET_ENV}): #{e.message}\n")
|
|
138
|
+
$stderr.flush
|
|
139
|
+
exit(1)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
new(socket, socket, $stderr)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def initialize(input, output, error)
|
|
146
|
+
@in = input
|
|
147
|
+
@out = output
|
|
148
|
+
@err = error
|
|
149
|
+
@checksum = true
|
|
150
|
+
@request_id = UNSOLICITED
|
|
151
|
+
@keep_alive = true
|
|
152
|
+
@stopping = false
|
|
153
|
+
@next_call_id = 0xFFFFFFFF
|
|
154
|
+
@call_handler = nil
|
|
155
|
+
@http_handler = nil
|
|
156
|
+
@job_handler = nil
|
|
157
|
+
@grpc_handler = nil
|
|
158
|
+
@last_call_was_known = true
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Write a diagnostic line to stderr.
|
|
162
|
+
#
|
|
163
|
+
# Never to the protocol stream: a line there would land inside a frame.
|
|
164
|
+
def diagnose(message)
|
|
165
|
+
@err.write("[volucer] #{message}\n")
|
|
166
|
+
@err.flush
|
|
167
|
+
rescue StandardError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The next frame header, or nil at a clean end of stream.
|
|
172
|
+
#
|
|
173
|
+
# A stream that ends before the first byte is a clean close — that is how the server
|
|
174
|
+
# asks a worker to stop. A stream that ends *part-way* through a header is an error,
|
|
175
|
+
# because treating it as a clean close would hide a truncated frame.
|
|
176
|
+
def read_header
|
|
177
|
+
raw = read_exactly(HEADER_LEN, allow_eof: true)
|
|
178
|
+
return nil if raw.nil?
|
|
179
|
+
|
|
180
|
+
Header.decode(raw)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Read a frame's payload and verify its checksum.
|
|
184
|
+
#
|
|
185
|
+
# A mismatch means framing has already desynchronised, so this is fatal by construction.
|
|
186
|
+
#
|
|
187
|
+
# A frame that declares NO_CHECKSUM is read without hashing it, but only when this
|
|
188
|
+
# worker actually agreed to that; otherwise the claim is a protocol violation.
|
|
189
|
+
def read_payload(header)
|
|
190
|
+
return ''.b if header.payload_len.zero?
|
|
191
|
+
|
|
192
|
+
# Refused *before* reading, which is the point of the limit: a corrupt `payload_len`
|
|
193
|
+
# must not make the reader allocate 4 GiB.
|
|
194
|
+
if header.payload_len > MAX_PAYLOAD_LEN
|
|
195
|
+
raise ProtocolError,
|
|
196
|
+
"a frame declares #{header.payload_len} payload bytes, above the " \
|
|
197
|
+
"#{MAX_PAYLOAD_LEN} limit; refusing it before allocating"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
payload = read_exactly(header.payload_len)
|
|
201
|
+
|
|
202
|
+
unless header.declares_no_checksum?
|
|
203
|
+
computed = Crc32c.digest(payload)
|
|
204
|
+
if computed != header.checksum
|
|
205
|
+
raise ProtocolError,
|
|
206
|
+
"payload checksum mismatch on request #{header.request_id}: " +
|
|
207
|
+
format('expected 0x%08x, computed 0x%08x', header.checksum, computed)
|
|
208
|
+
end
|
|
209
|
+
return payload
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
if @checksum
|
|
213
|
+
raise ProtocolError,
|
|
214
|
+
"the server sent a #{header.type_name} frame declaring no checksum, which " \
|
|
215
|
+
'this worker never agreed to'
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
payload
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Write one frame, in full.
|
|
222
|
+
def send_frame(frame)
|
|
223
|
+
@out.write(frame.encode(checksum: @checksum))
|
|
224
|
+
@out.flush
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def send_json(message_type, request_id, value)
|
|
228
|
+
send_frame(Frame.new(message_type, request_id, Volucer.json_bytes(value)))
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Read the HELLO, answer it, and adopt the checksum agreement.
|
|
232
|
+
#
|
|
233
|
+
# The answer is sent *before* the agreement is adopted, so the HELLO_ACK always carries
|
|
234
|
+
# a real checksum: both ends are still verifying at the moment the agreement is
|
|
235
|
+
# exchanged, and only then does the worker stop hashing.
|
|
236
|
+
def handshake
|
|
237
|
+
offer = false
|
|
238
|
+
|
|
239
|
+
loop do
|
|
240
|
+
header = read_header
|
|
241
|
+
raise ProtocolError, 'the server closed the pipe before the handshake' if header.nil?
|
|
242
|
+
|
|
243
|
+
payload = read_payload(header)
|
|
244
|
+
|
|
245
|
+
case header.message_type
|
|
246
|
+
when MessageType::HELLO
|
|
247
|
+
unless payload.empty?
|
|
248
|
+
hello = Volucer.decode_json(payload, 'a HELLO')
|
|
249
|
+
version = hello.fetch('protocol_version', PROTOCOL_VERSION)
|
|
250
|
+
unless version == PROTOCOL_VERSION
|
|
251
|
+
raise ProtocolError,
|
|
252
|
+
"the server speaks protocol version #{version}, this SDK speaks " \
|
|
253
|
+
"#{PROTOCOL_VERSION}"
|
|
254
|
+
end
|
|
255
|
+
# Only the JSON literal `true` counts: a field that switches off an integrity
|
|
256
|
+
# check must not be satisfied by a truthy value.
|
|
257
|
+
offer = hello['no_checksum'] == true
|
|
258
|
+
end
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
when MessageType::LOG, MessageType::METRIC, MessageType::EVENT
|
|
262
|
+
next
|
|
263
|
+
|
|
264
|
+
else
|
|
265
|
+
raise ProtocolError,
|
|
266
|
+
"expected a HELLO to begin the handshake, got a #{header.type_name}"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
send_json(MessageType::HELLO_ACK, UNSOLICITED, {
|
|
271
|
+
'protocol_version' => PROTOCOL_VERSION,
|
|
272
|
+
'php_version' => '',
|
|
273
|
+
'sapi' => '',
|
|
274
|
+
'pid' => Process.pid,
|
|
275
|
+
'runtime' => Volucer.runtime,
|
|
276
|
+
'no_checksum' => offer
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
@checksum = false if offer
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Handshake, then serve requests until the server closes the channel.
|
|
283
|
+
#
|
|
284
|
+
# The block receives the Request and this worker, and may return a Response, a String,
|
|
285
|
+
# an Array in Rack's `[status, headers, body]` shape, or nil for an empty body.
|
|
286
|
+
#
|
|
287
|
+
# Raising is allowed: the SDK catches it, reports it on stderr, and answers 500.
|
|
288
|
+
def run(&handler)
|
|
289
|
+
@http_handler = handler
|
|
290
|
+
handshake
|
|
291
|
+
|
|
292
|
+
until @stopping
|
|
293
|
+
header = read_header
|
|
294
|
+
# Clean EOF: the server closed our input, which is how a worker is asked to stop.
|
|
295
|
+
return if header.nil?
|
|
296
|
+
|
|
297
|
+
payload = read_payload(header)
|
|
298
|
+
dispatch(header, payload)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Handshake, then handle jobs until the server closes the channel.
|
|
303
|
+
#
|
|
304
|
+
# What the block returns decides the job's fate:
|
|
305
|
+
#
|
|
306
|
+
# * `nil` — **acknowledge.** Done.
|
|
307
|
+
# * `false` — **requeue.** Not done, and it should be handed out again.
|
|
308
|
+
# * a String — **reject**, with that string as the reason.
|
|
309
|
+
#
|
|
310
|
+
# A block that *raises* requeues, and that is the safe direction: a consumer that
|
|
311
|
+
# crashes must not lose the job, and an application that wanted a crashing job dropped
|
|
312
|
+
# can rescue its own exception and return a String — which is the visible, deliberate
|
|
313
|
+
# act this default pushes an application towards.
|
|
314
|
+
def consume(&handler)
|
|
315
|
+
@job_handler = handler
|
|
316
|
+
handshake
|
|
317
|
+
|
|
318
|
+
until @stopping
|
|
319
|
+
header = read_header
|
|
320
|
+
return if header.nil?
|
|
321
|
+
|
|
322
|
+
payload = read_payload(header)
|
|
323
|
+
dispatch(header, payload)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Register what answers a CALL the server makes while a request is in flight.
|
|
328
|
+
#
|
|
329
|
+
# The block receives the decoded call as a Hash with 'name' and 'params', and may
|
|
330
|
+
# return a value, or a Hash with 'ok' in it to refuse it — including
|
|
331
|
+
# `'unknown' => true` when the call may have taken effect, which the server treats
|
|
332
|
+
# differently from a refusal.
|
|
333
|
+
def on_call(&handler)
|
|
334
|
+
@call_handler = handler
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Register the block for gRPC calls, which arrive as ordinary REQUESTs.
|
|
338
|
+
def on_grpc(&handler)
|
|
339
|
+
@grpc_handler = handler
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Emit a log record without touching the protocol stream.
|
|
343
|
+
def emit_log(message, level = 'info')
|
|
344
|
+
send_json(MessageType::LOG, UNSOLICITED, {
|
|
345
|
+
'level' => level,
|
|
346
|
+
'message' => message,
|
|
347
|
+
'request_id' => @request_id
|
|
348
|
+
})
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Emit a metric sample.
|
|
352
|
+
#
|
|
353
|
+
# The name must be a Prometheus metric name — `[a-zA-Z_][a-zA-Z0-9_]*` — because that
|
|
354
|
+
# is what it becomes, and the deployment must declare it in `[metrics.collect]` before
|
|
355
|
+
# the server accepts it.
|
|
356
|
+
def emit_metric(name, value, unit = '')
|
|
357
|
+
send_json(MessageType::METRIC, UNSOLICITED, {
|
|
358
|
+
'name' => name,
|
|
359
|
+
'value' => Float(value),
|
|
360
|
+
'unit' => unit
|
|
361
|
+
})
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Ask the server for something, while a request is in flight.
|
|
365
|
+
#
|
|
366
|
+
# Only legal during a request: between requests the server's exchange thread is parked
|
|
367
|
+
# on its work channel and nothing drains the pipe, so a call made while idle would
|
|
368
|
+
# block until the request deadline killed the worker.
|
|
369
|
+
def call(name, params = {})
|
|
370
|
+
if @request_id == UNSOLICITED
|
|
371
|
+
raise ProtocolError,
|
|
372
|
+
'a call may only be made while a request is in flight; between requests ' \
|
|
373
|
+
'nothing on the server is reading this channel'
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
call_id = @next_call_id
|
|
377
|
+
@next_call_id = call_id == 1 ? 0xFFFFFFFF : call_id - 1
|
|
378
|
+
|
|
379
|
+
send_json(MessageType::CALL, call_id, { 'name' => name, 'params' => params })
|
|
380
|
+
|
|
381
|
+
loop do
|
|
382
|
+
header = read_header
|
|
383
|
+
raise ProtocolError, 'the server closed the channel while a call was in flight' if header.nil?
|
|
384
|
+
|
|
385
|
+
payload = read_payload(header)
|
|
386
|
+
|
|
387
|
+
if header.message_type == MessageType::RETURN
|
|
388
|
+
unless header.request_id == call_id
|
|
389
|
+
raise ProtocolError,
|
|
390
|
+
"a RETURN for call #{header.request_id} arrived while waiting for " \
|
|
391
|
+
"call #{call_id}"
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
answer = Volucer.decode_json(payload, 'a RETURN')
|
|
395
|
+
unless answer['ok']
|
|
396
|
+
@last_call_was_known = !answer['unknown']
|
|
397
|
+
raise ProtocolError.new(answer.fetch('error', 'the call was refused'), fatal: false)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
@last_call_was_known = true
|
|
401
|
+
return answer['result']
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Anything else arriving mid-call is not part of this exchange. Telemetry is
|
|
405
|
+
# absorbed; a correlated frame is a protocol error, because it means two exchanges
|
|
406
|
+
# are in flight on a channel that carries one.
|
|
407
|
+
next if [MessageType::LOG, MessageType::METRIC, MessageType::EVENT].include?(header.message_type)
|
|
408
|
+
|
|
409
|
+
raise ProtocolError,
|
|
410
|
+
"while waiting for a RETURN the server sent a #{header.type_name}, which " \
|
|
411
|
+
'this worker cannot serve'
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Ask to be replaced after the current unit of work.
|
|
416
|
+
#
|
|
417
|
+
# Reported as `keep_alive = false` in the terminal frame, which is also how a worker
|
|
418
|
+
# that handled a fatal error asks to be replaced.
|
|
419
|
+
def recycle
|
|
420
|
+
@keep_alive = false
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
private
|
|
424
|
+
|
|
425
|
+
def read_exactly(length, allow_eof: false)
|
|
426
|
+
chunks = +''
|
|
427
|
+
while chunks.bytesize < length
|
|
428
|
+
chunk = @in.read(length - chunks.bytesize)
|
|
429
|
+
if chunk.nil? || chunk.empty?
|
|
430
|
+
return nil if allow_eof && chunks.empty?
|
|
431
|
+
|
|
432
|
+
raise ProtocolError,
|
|
433
|
+
"the stream ended after #{chunks.bytesize} of #{length} bytes"
|
|
434
|
+
end
|
|
435
|
+
chunks << chunk
|
|
436
|
+
end
|
|
437
|
+
chunks
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def dispatch(header, payload)
|
|
441
|
+
case header.message_type
|
|
442
|
+
when MessageType::REQUEST
|
|
443
|
+
if @http_handler.nil? && @grpc_handler.nil?
|
|
444
|
+
diagnose(
|
|
445
|
+
'the server sent a REQUEST to a consumer; this worker is configured for the ' \
|
|
446
|
+
'wrong mode, and the request is being refused'
|
|
447
|
+
)
|
|
448
|
+
refuse_request(header)
|
|
449
|
+
else
|
|
450
|
+
serve(header, payload)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
when MessageType::JOB
|
|
454
|
+
if @job_handler.nil?
|
|
455
|
+
diagnose(
|
|
456
|
+
'the server sent a JOB to a worker that serves requests; this worker is ' \
|
|
457
|
+
'configured for the wrong mode, and the job is being refused'
|
|
458
|
+
)
|
|
459
|
+
refuse_job(header, payload)
|
|
460
|
+
else
|
|
461
|
+
handle_job(header, payload)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
when MessageType::PING
|
|
465
|
+
send_frame(Frame.new(MessageType::PONG, UNSOLICITED))
|
|
466
|
+
|
|
467
|
+
when MessageType::CONTROL
|
|
468
|
+
handle_control(payload)
|
|
469
|
+
|
|
470
|
+
when MessageType::CALL
|
|
471
|
+
answer_call(header, payload)
|
|
472
|
+
|
|
473
|
+
when MessageType::LOG, MessageType::METRIC, MessageType::EVENT
|
|
474
|
+
# A peer that echoes telemetry back must not derail the loop.
|
|
475
|
+
diagnose("ignoring an unexpected #{header.type_name} from the server")
|
|
476
|
+
|
|
477
|
+
else
|
|
478
|
+
raise ProtocolError,
|
|
479
|
+
"the server sent a #{header.type_name}, which a worker cannot serve"
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def serve(header, payload)
|
|
484
|
+
@request_id = header.request_id
|
|
485
|
+
@keep_alive = true
|
|
486
|
+
|
|
487
|
+
metadata = Volucer.decode_json(payload, 'a REQUEST header')
|
|
488
|
+
body = read_body(header.request_id, metadata.fetch('body_len', 0).to_i)
|
|
489
|
+
|
|
490
|
+
request = Request.new(
|
|
491
|
+
method: metadata.fetch('method', 'GET').to_s,
|
|
492
|
+
uri: metadata.fetch('uri', '/').to_s,
|
|
493
|
+
protocol: metadata.fetch('protocol', 'HTTP/1.1').to_s,
|
|
494
|
+
headers: pairs(metadata['headers']),
|
|
495
|
+
remote_addr: metadata.fetch('remote_addr', '').to_s,
|
|
496
|
+
script_filename: metadata.fetch('script_filename', '').to_s,
|
|
497
|
+
https: metadata['https'] == true,
|
|
498
|
+
body: body
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
502
|
+
app_status = 0
|
|
503
|
+
|
|
504
|
+
begin
|
|
505
|
+
result =
|
|
506
|
+
if request.grpc? && @grpc_handler
|
|
507
|
+
@grpc_handler.call(request, self)
|
|
508
|
+
elsif @http_handler
|
|
509
|
+
@http_handler.call(request, self)
|
|
510
|
+
else
|
|
511
|
+
# A gRPC call with no gRPC handler is UNIMPLEMENTED rather than a 500: a method
|
|
512
|
+
# the application does not implement is one it is entitled to refuse.
|
|
513
|
+
Response.text("Not Implemented\n", 501)
|
|
514
|
+
end
|
|
515
|
+
response = normalise(result)
|
|
516
|
+
rescue StandardError => e
|
|
517
|
+
diagnose("uncaught #{e.class} while serving #{request.uri}: #{e.message}")
|
|
518
|
+
response = Response.text("Internal Server Error\n", 500)
|
|
519
|
+
app_status = 1
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
duration_us = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1_000_000).to_i
|
|
523
|
+
respond(response, app_status, duration_us)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Read the request body from the BODY frames that follow a REQUEST.
|
|
527
|
+
#
|
|
528
|
+
# Stops at `body_len` bytes, because the declared length is authoritative.
|
|
529
|
+
def read_body(request_id, body_len)
|
|
530
|
+
return ''.b if body_len.zero?
|
|
531
|
+
|
|
532
|
+
body = +''
|
|
533
|
+
while body.bytesize < body_len
|
|
534
|
+
header = read_header
|
|
535
|
+
if header.nil?
|
|
536
|
+
raise ProtocolError,
|
|
537
|
+
"the server closed the pipe after #{body.bytesize} of #{body_len} body bytes"
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
payload = read_payload(header)
|
|
541
|
+
|
|
542
|
+
unless header.message_type == MessageType::BODY
|
|
543
|
+
raise ProtocolError,
|
|
544
|
+
"expected a BODY while reading the request body, got a #{header.type_name}"
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
unless header.request_id == request_id
|
|
548
|
+
raise ProtocolError,
|
|
549
|
+
"a BODY for request #{header.request_id} arrived while reading request " \
|
|
550
|
+
"#{request_id}"
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
body << payload
|
|
554
|
+
|
|
555
|
+
if header.last? && body.bytesize != body_len
|
|
556
|
+
raise ProtocolError,
|
|
557
|
+
"the last BODY frame arrived after #{body.bytesize} of #{body_len} declared bytes"
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
body
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Write a response: head, body chunks, then the terminal status.
|
|
565
|
+
def respond(response, app_status, duration_us)
|
|
566
|
+
body = response.body
|
|
567
|
+
|
|
568
|
+
send_json(MessageType::RESPONSE, @request_id, {
|
|
569
|
+
'status' => response.status,
|
|
570
|
+
'headers' => response.headers.map { |name, value| [name.to_s, value.to_s] },
|
|
571
|
+
'body_len' => body.bytesize
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
unless body.empty?
|
|
575
|
+
chunks = (body.bytesize + MAX_BODY_CHUNK - 1) / MAX_BODY_CHUNK
|
|
576
|
+
chunks.times do |index|
|
|
577
|
+
start = index * MAX_BODY_CHUNK
|
|
578
|
+
chunk = body.byteslice(start, MAX_BODY_CHUNK)
|
|
579
|
+
send_frame(Frame.new(
|
|
580
|
+
MessageType::BODY,
|
|
581
|
+
@request_id,
|
|
582
|
+
chunk,
|
|
583
|
+
index == chunks - 1 ? Flags::LAST : 0
|
|
584
|
+
))
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
send_json(MessageType::END_FRAME, @request_id, {
|
|
589
|
+
'app_status' => app_status,
|
|
590
|
+
'peak_memory_bytes' => peak_memory_bytes,
|
|
591
|
+
'duration_us' => duration_us,
|
|
592
|
+
'keep_alive' => @keep_alive
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
@stopping = true unless @keep_alive
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# The peak memory this worker reached, in bytes.
|
|
599
|
+
#
|
|
600
|
+
# **Per request rather than per process**, because the two behave differently and the
|
|
601
|
+
# difference decides how often a worker is recycled. Ruby's `GC.stat` reports heap
|
|
602
|
+
# slots rather than resident bytes, so this is the process's RSS — cumulative, and
|
|
603
|
+
# therefore documented as such. See the module docstring.
|
|
604
|
+
def peak_memory_bytes
|
|
605
|
+
Volucer.process_rss_bytes
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# Answer a REQUEST a consumer cannot serve, rather than ignoring it.
|
|
609
|
+
#
|
|
610
|
+
# The server's request path has a deadline and its answer to a missed one is to kill
|
|
611
|
+
# the worker, so silence would turn a one-line configuration mistake into a worker
|
|
612
|
+
# killed once a minute.
|
|
613
|
+
def refuse_request(header)
|
|
614
|
+
@request_id = header.request_id
|
|
615
|
+
respond(Response.text("Not Implemented: this worker is a consumer\n", 501), 1, 0)
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def handle_job(header, payload)
|
|
619
|
+
metadata = Volucer.decode_json(payload, 'a JOB header')
|
|
620
|
+
body = read_body(header.request_id, metadata.fetch('body_len', 0).to_i)
|
|
621
|
+
|
|
622
|
+
job = Job.new(
|
|
623
|
+
pipeline: metadata.fetch('pipeline', '').to_s,
|
|
624
|
+
id: metadata.fetch('id', '').to_s,
|
|
625
|
+
task: metadata.fetch('task', '').to_s,
|
|
626
|
+
headers: pairs(metadata['headers']),
|
|
627
|
+
attempts: metadata.fetch('attempts', 0).to_i,
|
|
628
|
+
body: body
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
632
|
+
outcome = 'ack'
|
|
633
|
+
reason = nil
|
|
634
|
+
|
|
635
|
+
begin
|
|
636
|
+
result = @job_handler.call(job, self)
|
|
637
|
+
if result == false
|
|
638
|
+
outcome = 'requeue'
|
|
639
|
+
elsif result.is_a?(String)
|
|
640
|
+
outcome = 'reject'
|
|
641
|
+
reason = result
|
|
642
|
+
end
|
|
643
|
+
rescue StandardError => e
|
|
644
|
+
# A crashing consumer must not lose work, and an application that wanted a crashing
|
|
645
|
+
# job dropped can rescue its own exception and return a String.
|
|
646
|
+
diagnose("uncaught #{e.class} while consuming job #{job.id}: #{e.message}")
|
|
647
|
+
outcome = 'requeue'
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
duration_us = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1_000_000).to_i
|
|
651
|
+
ack(header.request_id, outcome, reason, duration_us)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def refuse_job(header, payload)
|
|
655
|
+
metadata = Volucer.decode_json(payload, 'a JOB header')
|
|
656
|
+
body_len = metadata.fetch('body_len', 0).to_i
|
|
657
|
+
# The body still has to be drained, or the next frame is read as a body chunk.
|
|
658
|
+
read_body(header.request_id, body_len) unless body_len.zero?
|
|
659
|
+
ack(header.request_id, 'requeue', 'this worker serves requests, not jobs', 0)
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def ack(request_id, outcome, reason, duration_us)
|
|
663
|
+
payload = {
|
|
664
|
+
'outcome' => outcome,
|
|
665
|
+
'peak_memory_bytes' => peak_memory_bytes,
|
|
666
|
+
'keep_alive' => @keep_alive,
|
|
667
|
+
'duration_us' => duration_us
|
|
668
|
+
}
|
|
669
|
+
payload['reason'] = reason unless reason.nil?
|
|
670
|
+
send_json(MessageType::ACK, request_id, payload)
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def answer_call(header, payload)
|
|
674
|
+
call = Volucer.decode_json(payload, 'a CALL')
|
|
675
|
+
|
|
676
|
+
begin
|
|
677
|
+
if @call_handler.nil?
|
|
678
|
+
send_json(MessageType::RETURN, header.request_id, {
|
|
679
|
+
'ok' => false,
|
|
680
|
+
'error' => 'this worker registered no call handler, so ' \
|
|
681
|
+
"#{call.fetch('name', 'the call')} cannot be served"
|
|
682
|
+
})
|
|
683
|
+
return
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
result = @call_handler.call(call)
|
|
687
|
+
|
|
688
|
+
# A handler that wants to say anything other than "here is the value" returns a
|
|
689
|
+
# Hash with `ok` in it. Checking for the key rather than for a shape means a result
|
|
690
|
+
# that happens to be a Hash of data is still sent as a result.
|
|
691
|
+
if result.is_a?(Hash) && result.key?('ok')
|
|
692
|
+
send_json(MessageType::RETURN, header.request_id, {
|
|
693
|
+
'ok' => !!result['ok'],
|
|
694
|
+
'error' => result['error'],
|
|
695
|
+
'unknown' => !!result['unknown'],
|
|
696
|
+
'result' => result['result']
|
|
697
|
+
})
|
|
698
|
+
return
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
send_json(MessageType::RETURN, header.request_id, { 'ok' => true, 'result' => result })
|
|
702
|
+
rescue StandardError => e
|
|
703
|
+
diagnose("a call handler raised #{e.class}: #{e.message}")
|
|
704
|
+
send_json(MessageType::RETURN, header.request_id, {
|
|
705
|
+
'ok' => false,
|
|
706
|
+
'error' => "the call handler raised #{e.class}",
|
|
707
|
+
# Deliberately not unknown: the handler raised before returning, and
|
|
708
|
+
# whether its side effects happened is the handler's business to report.
|
|
709
|
+
'unknown' => false
|
|
710
|
+
})
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def handle_control(payload)
|
|
715
|
+
control = Volucer.decode_json(payload, 'a CONTROL')
|
|
716
|
+
kind = control['kind'].is_a?(String) ? control['kind'] : ''
|
|
717
|
+
|
|
718
|
+
send_frame(Frame.new(MessageType::CONTROL_ACK, UNSOLICITED))
|
|
719
|
+
|
|
720
|
+
case kind
|
|
721
|
+
when 'drain', 'shutdown'
|
|
722
|
+
# We are idle between requests, so this is immediate.
|
|
723
|
+
@stopping = true
|
|
724
|
+
when 'report'
|
|
725
|
+
emit_metric('worker_peak_memory_bytes', peak_memory_bytes.to_f, 'bytes')
|
|
726
|
+
when 'reload'
|
|
727
|
+
diagnose('reload acknowledged: this worker has no separate bootstrap')
|
|
728
|
+
else
|
|
729
|
+
diagnose("unknown control kind #{kind.inspect} acknowledged and ignored")
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def pairs(value)
|
|
734
|
+
return [] unless value.is_a?(Array)
|
|
735
|
+
|
|
736
|
+
value.filter_map do |pair|
|
|
737
|
+
next unless pair.is_a?(Array) && pair.length == 2
|
|
738
|
+
|
|
739
|
+
[pair[0].to_s, pair[1].to_s]
|
|
740
|
+
end
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# Turn whatever the block returned into a Response.
|
|
744
|
+
#
|
|
745
|
+
# The Array form is Rack's `[status, headers, body]`, because that is what a framework
|
|
746
|
+
# adapter naturally produces and converting at the boundary is cheaper than making
|
|
747
|
+
# every adapter build the SDK's type.
|
|
748
|
+
def normalise(result)
|
|
749
|
+
case result
|
|
750
|
+
when Response
|
|
751
|
+
result
|
|
752
|
+
when Array
|
|
753
|
+
status, headers, body = result
|
|
754
|
+
text = body.respond_to?(:each) ? body.each_with_object(+'') { |chunk, out| out << chunk } : body.to_s
|
|
755
|
+
Response.new(status.to_i, normalise_headers(headers), text)
|
|
756
|
+
when nil
|
|
757
|
+
Response.html('')
|
|
758
|
+
when String
|
|
759
|
+
Response.html(result)
|
|
760
|
+
else
|
|
761
|
+
raise ProtocolError,
|
|
762
|
+
'a worker block must return a Response, a String, a [status, headers, body] ' \
|
|
763
|
+
"array or nil, got #{result.class}"
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def normalise_headers(headers)
|
|
768
|
+
return [] unless headers.respond_to?(:each)
|
|
769
|
+
|
|
770
|
+
collected = []
|
|
771
|
+
headers.each do |pair|
|
|
772
|
+
name, value = pair
|
|
773
|
+
if value.is_a?(Array)
|
|
774
|
+
# Rack 3 allows an Array value for a header that may repeat; two cookies are two
|
|
775
|
+
# headers, and joining them would silently break a session.
|
|
776
|
+
value.each { |one| collected << [name.to_s, one.to_s] }
|
|
777
|
+
else
|
|
778
|
+
collected << [name.to_s, value.to_s]
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
collected
|
|
782
|
+
end
|
|
783
|
+
end
|
|
784
|
+
|
|
785
|
+
# The process's resident set size, or 0 when the platform will not say.
|
|
786
|
+
#
|
|
787
|
+
# Zero is the protocol's "this runtime does not report it", and the pool must not recycle
|
|
788
|
+
# on a value it did not get — so an honest zero is better than a guess.
|
|
789
|
+
def self.process_rss_bytes
|
|
790
|
+
if Gem.win_platform?
|
|
791
|
+
# `GetProcessMemoryInfo` is the only portable-enough answer on Windows; Ruby has no
|
|
792
|
+
# built-in for it, and shelling out per request would cost more than the number is
|
|
793
|
+
# worth. `WorkingSetSize` from the process's own counters is the figure Task Manager
|
|
794
|
+
# shows, which is the one an operator compares against `memory_limit_mb`.
|
|
795
|
+
#
|
|
796
|
+
# `fiddle` stopped being a default gem in Ruby 4.0. It is still *usually* present as a
|
|
797
|
+
# bundled gem, so a plain `require 'fiddle'` succeeds outside bundler and raises
|
|
798
|
+
# `LoadError` under it unless the Gemfile lists it. Rescuing is the right answer rather
|
|
799
|
+
# than declaring a dependency: RSS reporting is optional, and an honest 0 is the
|
|
800
|
+
# protocol's "this runtime does not report it". A gem that refuses to load because it
|
|
801
|
+
# cannot measure memory would be worse than one that reports 0.
|
|
802
|
+
begin
|
|
803
|
+
require 'fiddle'
|
|
804
|
+
rescue LoadError
|
|
805
|
+
return 0
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
kernel32 = Fiddle.dlopen('kernel32')
|
|
809
|
+
get_current_process = Fiddle::Function.new(
|
|
810
|
+
kernel32['GetCurrentProcess'], [], Fiddle::TYPE_VOIDP
|
|
811
|
+
)
|
|
812
|
+
# `TYPE_UINT32` is not a Fiddle constant; the unsigned fixed-width names carry a `_T`
|
|
813
|
+
# suffix like their C counterparts. The wrong name raises `NameError` on the *first*
|
|
814
|
+
# call, which the `rescue StandardError` at the bottom of this method turned into a
|
|
815
|
+
# silent 0 — so Windows RSS reporting never worked and nothing reported that it did not.
|
|
816
|
+
get_memory_info = Fiddle::Function.new(
|
|
817
|
+
kernel32['K32GetProcessMemoryInfo'],
|
|
818
|
+
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_UINT32_T],
|
|
819
|
+
Fiddle::TYPE_INT
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
# PROCESS_MEMORY_COUNTERS is 10 DWORD/pointer-sized fields; 64 bytes is enough on a
|
|
823
|
+
# 32-bit build and 96 on a 64-bit one, so it is allocated generously.
|
|
824
|
+
buffer = Fiddle::Pointer.malloc(96)
|
|
825
|
+
return 0 if get_memory_info.call(get_current_process.call, buffer, 96).zero?
|
|
826
|
+
|
|
827
|
+
# Field order: cb, PageFaultCount, PeakWorkingSetSize, WorkingSetSize, …
|
|
828
|
+
buffer[8, 8].unpack1('Q') || 0
|
|
829
|
+
else
|
|
830
|
+
statm = File.read('/proc/self/statm').split
|
|
831
|
+
Integer(statm[1]) * Integer(`getconf PAGESIZE`.strip)
|
|
832
|
+
end
|
|
833
|
+
rescue StandardError
|
|
834
|
+
0
|
|
835
|
+
end
|
|
836
|
+
end
|