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,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