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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 90e630f897db56c1270cb42f99d874e07343f625481310306926cb19068b4c91
4
+ data.tar.gz: 8a822412a40e30e2892ce2a44ed202ef5a5549d9f81b0cb8e44c16c895af577c
5
+ SHA512:
6
+ metadata.gz: 2093ebaa9b65e4106db42e27605481d8ff46043fe4ebb94233376dde6a03eefac2cc2d86dfa75f4263953e60ece2add4e52ed470cd1266618f0207655da17c5f
7
+ data.tar.gz: '030794b6447b7e80e50fe1384599ccc540441972d752160dfc1db9e1cd9050e343a1a1cad8c379430ac1a1660300f9e37bea5d1e932c4b5525f4271d4e3fdfa7'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sailantis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,442 @@
1
+ # volucer-ruby-sdk
2
+
3
+ Ruby bindings for [volucer](https://github.com/sailantis/volucer): the `kv`, `lock` and
4
+ `jobs` services, and the worker protocol.
5
+
6
+ The specification is [`docs/worker-protocol.md`](https://github.com/sailantis/volucer/blob/main/docs/worker-protocol.md)
7
+ in the server repository. This gem is the other end of it, beside the PHP one in
8
+ [`sailantis/volucer-php-sdk`](https://github.com/sailantis/volucer-php-sdk) and the Python
9
+ one in [`sailantis/volucer-python-sdk`](https://github.com/sailantis/volucer-python-sdk). All
10
+ four implement the same document rather than each other, and the wire-format tests in each
11
+ assert the same frozen bytes.
12
+
13
+ ## Which half you want
14
+
15
+ There are two ways to reach volucer from Ruby, and most deployments want the first.
16
+
17
+ **`Rpc` — the services, without changing how your app is served.** You keep Puma, Falcon or
18
+ Thin, keep your container, keep your process model. Your app gains volucer's `kv`, `lock` and
19
+ `jobs` by dialling a socket volucer gives every request:
20
+
21
+ ```ruby
22
+ rpc = Volucer::Rpc.connect(env: env) # `env` is the Rack environment
23
+ rpc.kv('cache').set('greeting', 'hello')
24
+ ```
25
+
26
+ That is the whole setup, and it is what the rest of this gem is arranged around. See
27
+ [Reaching `kv`, `lock` and `jobs` without being a worker](#reaching-kv-lock-and-jobs-without-being-a-worker).
28
+
29
+ **`Worker` — let volucer serve the app instead of Puma.** The application boots once and volucer
30
+ sends it requests over the child's stdin/stdout, so a framework's bootstrap cost stops being
31
+ charged per request — a Rails boot is milliseconds, and on a small route it is most of the
32
+ request. This is worth it only if you want the channel itself: recycling on RSS, no HTTP framing
33
+ on the hot path, one process per slot. If you are happy with Puma, **use the `upstream` backend
34
+ instead** — volucer supervises Puma over HTTP, needs no gem at all, and is what the plan
35
+ recommends first.
36
+
37
+ ## Reaching `kv`, `lock` and `jobs` without being a worker
38
+
39
+ Everything above needs a worker's channel. A **Rack application served by FastCGI has no such
40
+ channel** — it exists for one request and can only answer the frame it was handed — so volucer
41
+ serves the same namespaces on a socket the application *dials*, `[rpc]`, and this gem provides the
42
+ client:
43
+
44
+ ```toml
45
+ [backends.app]
46
+ kind = "external"
47
+ socket = "unix:/run/php/php8.3-fpm.sock"
48
+ services = ["kv.cache", "lock", "jobs"]
49
+
50
+ [kv.cache]
51
+ driver = "memory"
52
+
53
+ [rpc]
54
+ listen = "unix:/run/volucer/rpc.sock"
55
+ ```
56
+
57
+ ```ruby
58
+ rpc = Volucer::Rpc.connect(env: env) # `env` is the Rack environment
59
+
60
+ rpc.kv('cache').set('greeting', 'hello')
61
+ rpc.kv('cache').get('greeting') # => "hello"
62
+
63
+ holder = rpc.lock.acquire('pdf:create') # => an id, or nil for contention
64
+ rpc.lock.release('pdf:create', holder)
65
+
66
+ rpc.jobs('mail').push('SendEmail', payload: '{"to":"a@b"}')
67
+ ```
68
+
69
+ **Pass `env:` and volucer does the configuring.** It writes `VOLUCER_RPC` and `VOLUCER_RPC_TOKEN`
70
+ into every request the backend serves — as CGI parameters under FastCGI, or as `X-Volucer-Rpc`
71
+ headers for an `upstream` origin, which Rack presents as `HTTP_X_VOLUCER_RPC`. One deployment
72
+ states the address once, in one file, and both sides read it. The process's own `ENV` is *not*
73
+ where those arrive, which is why the `env:` argument exists; a CLI script or a queue consumer
74
+ omits it and gets `ENV`, which is correct there.
75
+
76
+ Nothing is dialled by `connect` — a request that never touches a store pays for neither a socket
77
+ nor a failed connect — and a backend whose `services` list is empty still gets a plane: the *call*
78
+ fails, with the missing variable named, so a request that never touches a store keeps working and
79
+ the one that does gets an error pointing at the deployment.
80
+
81
+ ### What is different from a worker's channel
82
+
83
+ | | A worker's channel | `[rpc]` |
84
+ | --- | --- | --- |
85
+ | Handshake | `HELLO`/`HELLO_ACK` once | none — version and checksum policy are settled by the frames |
86
+ | Identity | the connection | a `token` on every `CALL` |
87
+ | Deadline | the protocol guarantees an answer | the client's, default **10 s** |
88
+ | Undecided calls | impossible | `ProtocolError#unknown?` |
89
+
90
+ The deadline is the one thing to plan for: it has to cover a `lock.acquire` that was asked to
91
+ **wait**, or the call is abandoned mid-wait and the lock's state is unknown. Ask for a longer one —
92
+ `Volucer::Rpc.connect(timeout: 30)` — rather than treating a timeout as contention.
93
+
94
+ ### Bytes, and where they are base64
95
+
96
+ The server stores opaque bytes, and the JSON codec cannot carry a byte above `0x7f`, so every field
97
+ it treats as bytes is base64 on the wire: a KV key, a KV value, a job payload. A lock's `resource`
98
+ is the exception, because a name is a name. `Kv` and `Jobs` do this encoding for you; it matters
99
+ when you read the wire or write to the same storage from another language.
100
+
101
+ A KV value is JSON by default, so `set('k', { 'a' => 1 })` returns the Hash it was given. A value
102
+ that is a **binary** `String` is sent as given rather than JSON-wrapped — the opt-out a caller who
103
+ compressed or encrypted it needs. `Kv#serializer(encode, decode)` replaces the pair entirely.
104
+
105
+ ### Two units, and one of them is easy to get wrong
106
+
107
+ A KV `ttl` is **seconds**; a lock's `ttl` and `wait` are **microseconds**. Copying one into the
108
+ other produces a lock held for a month, or a cache entry that expires before the response is
109
+ written.
110
+
111
+ ```ruby
112
+ rpc.kv('cache').set('session', data, ttl: 3600) # an hour
113
+ rpc.lock.acquire('pdf:create', ttl: 30_000_000) # thirty seconds
114
+ ```
115
+
116
+ ## What a worker is
117
+
118
+ Worker mode boots an application once and serves many requests from that process, instead of
119
+ booting it per request. It is the RoadRunner/Swoole/FrankenPHP value proposition, and it is
120
+ why a real framework's bootstrap cost stops being charged on every request — a Rails boot is
121
+ milliseconds, and on a small route it is most of the request.
122
+
123
+ A Ruby application already has servers that do this — Puma, Falcon, Thin. The reason to use
124
+ this SDK instead is the channel rather than the model: it runs over the child's stdin/stdout
125
+ (or a loopback socket on Windows), so the application keeps its own process, its own
126
+ container and its own warm caches, and volucer supervises it without wrapping it in a
127
+ protocol library first.
128
+
129
+ ## Install
130
+
131
+ ```console
132
+ gem install volucer-worker
133
+ ```
134
+
135
+ Or, in a Gemfile:
136
+
137
+ ```ruby
138
+ gem 'volucer-worker'
139
+ ```
140
+
141
+ No runtime dependencies beyond the standard library, deliberately: a worker SDK is loaded
142
+ before an application's own gems are known to work, and a dependency here is a failure mode
143
+ unrelated to the protocol.
144
+
145
+ ## A worker, in three lines
146
+
147
+ Any Rack application works, including a Rails app:
148
+
149
+ ```ruby
150
+ require 'volucer'
151
+ require 'my_app'
152
+
153
+ Volucer::Worker.connect.run(&Volucer::Rack.handler(MyApp.new))
154
+ ```
155
+
156
+ `Volucer::Worker.connect` honours `VOLUCER_WORKER_SOCKET` and falls back to stdio. It is the
157
+ call a worker file should make; `Volucer::Worker.create` is the explicit-streams form, used
158
+ when you are binding streams yourself — including when the connection was opened by somebody
159
+ else, which is what the `attach` backend needs.
160
+
161
+ The variable carries either form, and `connect` decides from its prefix:
162
+
163
+ | value | transport |
164
+ | --- | --- |
165
+ | `127.0.0.1:34567` | loopback TCP — the ephemeral port the server bound |
166
+ | `unix:/run/volucer/worker.0.sock` | a unix socket, from `socket = "unix:/run/volucer/worker"` |
167
+
168
+ `Volucer.unix_path` is the whole of the decision and returns `nil` for a TCP address, so the
169
+ connection code has one branch. `unix:` is the same convention the server's configuration uses for
170
+ `external`, `attach` and the worker `socket` key, so there is one spelling rather than a fourth.
171
+
172
+ ## Attaching instead of spawning
173
+
174
+ The `worker` backend starts your process. The `attach` backend does not: it *dials* a relay
175
+ that a supervisor in your own process listens on, and holds one connection per slot. That is
176
+ the shape that lets a supervisor fork its workers after booting the application once, so every
177
+ child inherits the interpreter, the loaded classes and Rails' initialisation instead of paying
178
+ for them again.
179
+
180
+ The split matters: **volucer cannot own the fork.** It has to happen in the application's
181
+ process, because the side that owns the listening socket has to be the side that owns the fork
182
+ — and because `fork` in a process that already has threads is not something a library can
183
+ decide safely. So the fork lives in `Volucer::Supervisor`, and you call it:
184
+
185
+ ```ruby
186
+ # supervisor.rb — boot once, then fork
187
+ require "volucer"
188
+ require_relative "config/environment" # Rails' own boot
189
+
190
+ # Boot before the fork: whatever this closes over is inherited by every child.
191
+ handler = Volucer::Rack.handler(Rails.application)
192
+
193
+ Volucer::Supervisor.serve(nil, handler) # or "unix:/run/app/workers.sock"
194
+ ```
195
+
196
+ ```toml
197
+ [backends.rails]
198
+ kind = "attach"
199
+ workers = 16
200
+ ```
201
+
202
+ **The address can be written once.** With `nil` as the first argument the supervisor reads
203
+ `VOLUCER_RELAY_SOCKET`, and so does the server when `relay` is omitted:
204
+
205
+ ```ini
206
+ # the unit file, or a container's environment
207
+ Environment=VOLUCER_RELAY_SOCKET=unix:/run/app/workers.sock
208
+ ```
209
+
210
+ That is the counterpart of `VOLUCER_WORKER_SOCKET`, for the mirror-image reason: that one is set
211
+ *by* the server on a child it spawned, so a supervisor cannot read it — no child was spawned. This
212
+ one is set by the operator, and both sides read it. A value passed to `serve`, or a `relay` key in
213
+ the file, still wins, so a stale variable cannot move a deployment. `Supervisor.relay` is that
214
+ resolution on its own.
215
+
216
+ `children` caps how many workers may run at once, and omitting it means **no cap**, which is
217
+ what an `attach` deployment wants: volucer dials `workers` times at startup and holds one
218
+ connection per slot, so the live children are already bounded by that number. A cap **below**
219
+ `workers` is not a queue — the surplus dials sit unaccepted in the listen backlog while the pool
220
+ waits for a handshake no child is there to send.
221
+
222
+ Each child serves exactly one connection and exits. That is what makes recycling cheap — when
223
+ `max_requests` is reached volucer closes the slot, the child sees end-of-file, exits, and the
224
+ supervisor forks a replacement for the next connection. The supervisor reaps its children, so
225
+ a long-lived one does not fill the process table with zombies.
226
+
227
+ **Unix only.** `Process.fork` does not exist on Windows, and `serve` raises there rather than
228
+ silently running a single process. Use `kind = "worker"` or `kind = "upstream"` on Windows.
229
+
230
+ **Boot before the fork, connect after it.** The application and its loaded classes are
231
+ inherited by every child, which is the saving. An *open connection* must not be: every child
232
+ would inherit the socket and they would take turns reading from it. Rails is safe here because
233
+ ActiveRecord checks connections out lazily, per request, and `ActionDispatch::Executor` returns
234
+ them to the pool afterwards — which is why `Volucer::Rack.handler` is the right wrapper for a
235
+ Rails app and a bare `proc` is not.
236
+
237
+ ### Writing a supervisor yourself
238
+
239
+ `Volucer::Supervisor.serve` is a convenience, not the contract. What `attach` needs from any
240
+ supervisor is that it serves a connection **it** opened, which is `Worker.create`:
241
+
242
+ ```ruby
243
+ require "socket"
244
+
245
+ listener = TCPServer.new("127.0.0.1", 9700)
246
+
247
+ loop do
248
+ connection = listener.accept
249
+ child = fork do
250
+ listener.close
251
+ Volucer::Worker.create(connection, connection).run(&handler)
252
+ exit!(0)
253
+ end
254
+ connection.close # the master must drop its copy
255
+ end
256
+ ```
257
+
258
+ No wrapper is needed here, unlike Python: a `TCPSocket` already responds to `read` and `write`,
259
+ which is what the worker calls on its streams. `exit!` rather than `exit` skips the master's
260
+ `at_exit` handlers and buffer flushing, which every child would otherwise run again.
261
+
262
+ ## Rails
263
+
264
+ Rails' `Application` object is already a Rack app, so there is nothing to adapt:
265
+
266
+ ```ruby
267
+ # worker.rb
268
+ require "volucer"
269
+ require_relative "config/environment" # Rails' own boot
270
+
271
+ Volucer::Worker.connect.run(&Volucer::Rack.handler(Rails.application))
272
+ ```
273
+
274
+ ```toml
275
+ [backends.rails]
276
+ kind = "worker"
277
+ command = "ruby worker.rb"
278
+ workers = 4
279
+
280
+ [server]
281
+ script_extensions = ["rb"] # else /worker.rb is PATH_INFO, not a routed script
282
+ ```
283
+
284
+ That is the whole integration. **You do not need `Rails.application.executor.wrap`**:
285
+ `ActionDispatch::Executor` is in `Rails::Application`'s default middleware stack, so
286
+ `Rails.application.call(env)` is already wrapped and ActiveRecord connections return to the
287
+ pool after each request. In development `Rails.application.reloader` is likewise already in
288
+ the stack, so code reload keeps working.
289
+
290
+ Verified against Rails 8.1.4 and a real `volucer` binary: routing, `request.original_url`,
291
+ `request.host`, sessions in both directions, Active Record across consecutive requests, the
292
+ pool's checked-out count returning to zero, and a 404 staying a 404.
293
+
294
+ ## Sinatra and other Rack apps
295
+
296
+ Identical, with your app in place of `Rails.application`. A bare Rack app that holds a
297
+ connection pool should wrap `call` itself with its own executor — that is the one case where
298
+ the wrapping is yours rather than the framework's.
299
+
300
+ Point a backend at it:
301
+
302
+ ```toml
303
+ [backends.ruby]
304
+ kind = "worker"
305
+ command = "ruby worker.rb"
306
+ workers = 4
307
+ memory_limit_mb = 256
308
+ script_extensions = ["rb"]
309
+ ```
310
+
311
+ Two configuration keys are not optional for a non-PHP worker:
312
+
313
+ - `script_extensions` must name the extension your entry point has, or `/app.rb` arrives at
314
+ the front controller as `PATH_INFO` instead of being routed. A list naming `rb` *replaces*
315
+ the default rather than adding to it, so a site serving Ruby stops treating `.php` as a
316
+ script — which is what "this tree is Ruby" means.
317
+ - `workers` is the concurrency, because the protocol carries **one request in flight per
318
+ worker process**. Puma with threads is what a Ruby deployment normally uses; this channel
319
+ is the opposite trade, and it is deliberate rather than a gap.
320
+
321
+ ## The two Ruby-specific answers
322
+
323
+ Everything else in this gem is a transliteration of the specification. These two are not.
324
+
325
+ ### stdout is the channel, so the SDK takes it
326
+
327
+ The protocol uses the child's stdout as its channel. PHP solves this with an output buffer
328
+ around `echo`, the Python SDK rebinds `sys.stdout`, and Ruby's answer is that **requiring
329
+ `volucer` reassigns `$stdout`** to a stream that writes to `$stderr`, keeping the real channel
330
+ in `Volucer::CHANNEL_OUT`.
331
+
332
+ The consequence is worth stating plainly: `puts` still reaches a human (through stderr, which
333
+ the supervisor drains), but it will not appear on the process's own stdout. A worker that did
334
+ not do this would corrupt its framing the first time somebody left a `puts` in, and the
335
+ symptom would be a killed worker rather than a stray line.
336
+
337
+ `Rails.logger` goes wherever it is configured to go, and the Rails default in a worker
338
+ process is not stdout — but a `puts` in a controller is exactly the case this protects
339
+ against.
340
+
341
+ ### RSS has no exact per-request answer in Ruby
342
+
343
+ The server recycles a worker whose reported `peak_memory_bytes` exceeds `memory_limit_mb`,
344
+ and the protocol wants that figure **per request, not per process**: a high-water mark that
345
+ never falls would turn a leak detector into a per-request application boot.
346
+
347
+ PHP has `memory_get_peak_usage()` plus `memory_reset_peak_usage()`, which is the right shape.
348
+ Ruby does not: `GC.stat` reports heap slots rather than resident bytes, and neither it nor
349
+ `ObjectSpace` answers "how much memory did that request cost". So this gem reports the
350
+ process's **resident set size** — `K32GetProcessMemoryInfo` on Windows, `/proc/self/statm`
351
+ elsewhere — and that number is cumulative by nature.
352
+
353
+ | Reported | Behaviour |
354
+ | --- | --- |
355
+ | The process RSS | Cheap, and **cannot fall**, so a peak that once exceeded `memory_limit_mb` recycles the worker after every request |
356
+
357
+ The practical advice is the same as for Python: report what you can, and treat
358
+ `memory_limit_mb` as a ceiling rather than a per-request budget. A worker that reports `0`
359
+ ("this runtime does not report it") is also accepted by the server, and one that does not
360
+ report is never recycled on memory. Override `Worker#peak_memory_bytes` — it is a private
361
+ method on a class you control — if you want to feed it `jemalloc`'s numbers or a
362
+ `GC.stat(:heap_allocated_pages)`-derived estimate instead.
363
+
364
+ ## The parts of the protocol a handler sees
365
+
366
+ | Frame | Handler surface |
367
+ | --- | --- |
368
+ | `REQUEST` / `RESPONSE` / `BODY` / `END` | `Worker#run { }`; the block returns a `Response`, a `String`, a Rack `[status, headers, body]` array, or `nil` |
369
+ | `JOB` / `ACK` | `Worker#consume { }`; `nil` acknowledges, `false` requeues, a `String` rejects with a reason, and a raise requeues |
370
+ | `CALL` / `RETURN` | `Worker#call(name, params)` from inside a block; `Worker#on_call { }` answers the server's calls. Outside a worker, `Volucer::Rpc` does the same over `[rpc]` |
371
+ | `LOG` / `METRIC` | `Worker#emit_log`, `Worker#emit_metric` |
372
+ | `CONTROL` | Answered by the SDK; `drain` and `shutdown` stop the loop |
373
+ | `PING` / `PONG` | Answered by the SDK |
374
+
375
+ `Worker#recycle` asks to be replaced after the current request, reported as
376
+ `keep_alive = false` in `END`.
377
+
378
+ ## gRPC
379
+
380
+ A gRPC call arrives as an ordinary `REQUEST` whose `content-type` is `application/grpc…`, and
381
+ its outcome travels in the reply's trailer section rather than in the HTTP status. The gem
382
+ exposes that as `Volucer::GrpcResponse`:
383
+
384
+ ```ruby
385
+ worker = Volucer::Worker.connect
386
+ worker.on_grpc { |request, _worker| Volucer::GrpcResponse.message(encode_reply(request)) }
387
+ worker.run { |request, _worker| handle(request) }
388
+ ```
389
+
390
+ `GrpcResponse.message(payload)` takes the **framed bytes** — the compression flag and the
391
+ big-endian length included. This gem does not ship a protobuf encoder, because doing so would
392
+ put a schema in the transport.
393
+
394
+ ## Tests
395
+
396
+ ```console
397
+ rake test
398
+ ```
399
+
400
+ The wire-format tests assert the same frozen bytes as
401
+ `crates/volucer-worker/tests/wire_format.rs`, `volucer-php-sdk/tests/WireFormatTest.php` and
402
+ `volucer-python-sdk/tests/test_wire_format.py`, so all four cannot drift apart silently.
403
+
404
+ The end-to-end test drives a real `volucer` binary and is skipped unless one is found:
405
+
406
+ ```console
407
+ VOLUCER_BIN=/path/to/volucer rake test
408
+ ```
409
+
410
+ It looks for `../volucer/target/debug/volucer` when `VOLUCER_BIN` is unset, so a developer
411
+ working in the sibling checkout needs no environment at all. `VOLUCER_E2E=1` turns a skip
412
+ into a failure, which is what CI sets — a skipped end-to-end test and a passing one are the
413
+ same green.
414
+
415
+ ## What this gem does not do
416
+
417
+ - **Falcon/async or thread-per-request concurrency inside one worker.** The channel carries
418
+ one request at a time, so that is a protocol change rather than an adapter.
419
+ - **Streaming responses.** `RESPONSE` declares `body_len` before the body, so a response is
420
+ complete before it starts. A protocol property, not a missing feature here.
421
+ - **Per-request state reset for your framework.** The Rack environment is rebuilt by the gem,
422
+ so that much is reset; a framework that keeps module-level state or a cached connection has
423
+ to deal with it itself. **Rails does not need anything extra**: `ActionDispatch::Executor` is
424
+ in `Rails::Application`'s default middleware stack, so `Rails.application.call(env)` is
425
+ already wrapped and ActiveRecord connections go back to the pool on their own — verified by
426
+ driving two requests through a real `volucer` binary and seeing the pool's checked-out count
427
+ return to zero. `Rails.application.executor.wrap` is the answer for a **bare Rack app** that
428
+ shares a connection pool, or for a caller driving the app outside Rack, not for this gem's
429
+ Rails path. The check that matters is two consecutive requests that must not see each other's
430
+ state, and `test/test_end_to_end.rb` asserts exactly that for the gem's own surface.
431
+ - **Forking on Windows.** `Volucer::Supervisor.serve` needs `Process.fork`. The platform without
432
+ it is served by `kind = "worker"` or `kind = "upstream"`, and the end-to-end suite proves the
433
+ `attach` contract on Windows with a threaded supervisor instead.
434
+ - **A job consumer over `[rpc]`.** `[rpc]` is the *producer* side: `jobs.push`, `jobs.push_batch`,
435
+ `jobs.size`, `jobs.pipelines`. Reserving a job is what a consumer's own channel is for, because a
436
+ reserved job's lease starts ticking — so a Rack application enqueues, and a `Volucer::Worker`
437
+ consumes.
438
+
439
+ `Rpc` is the one part that does not need the worker channel at all — see
440
+ [Reaching `kv`, `lock` and `jobs` without being a worker](#reaching-kv-lock-and-jobs-without-being-a-worker),
441
+ which is the option to reach for when `upstream` is the right deployment shape but the application
442
+ still needs a cache and a lock.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The outcome of one gRPC call.
4
+ #
5
+ # A gRPC reply is not an HTTP response, and modelling it as one is the mistake this class
6
+ # exists to prevent. Its **outcome travels in the trailer section** — after the body, not in
7
+ # the status line — because a streaming reply has to be able to send headers before its
8
+ # outcome exists. So a handler says what happened by naming a status here, and the server
9
+ # moves it to where a client reads it.
10
+ #
11
+ # `payload` is the **body**: the raw, length-delimited framed bytes the client receives. The
12
+ # server never parses or re-frames it, so a handler owns the framing entirely. This SDK does
13
+ # not ship an encoder, because doing so would put a schema in the transport.
14
+
15
+ module Volucer
16
+ class GrpcResponse
17
+ OK = 0
18
+ CANCELLED = 1
19
+ UNKNOWN = 2
20
+ INVALID_ARGUMENT = 3
21
+ DEADLINE_EXCEEDED = 4
22
+ NOT_FOUND = 5
23
+ ALREADY_EXISTS = 6
24
+ PERMISSION_DENIED = 7
25
+ RESOURCE_EXHAUSTED = 8
26
+ FAILED_PRECONDITION = 9
27
+ ABORTED = 10
28
+ OUT_OF_RANGE = 11
29
+ UNIMPLEMENTED = 12
30
+ INTERNAL = 13
31
+ UNAVAILABLE = 14
32
+ DATA_LOSS = 15
33
+ UNAUTHENTICATED = 16
34
+
35
+ NAMES = {
36
+ OK => 'OK', CANCELLED => 'CANCELLED', UNKNOWN => 'UNKNOWN',
37
+ INVALID_ARGUMENT => 'INVALID_ARGUMENT', DEADLINE_EXCEEDED => 'DEADLINE_EXCEEDED',
38
+ NOT_FOUND => 'NOT_FOUND', ALREADY_EXISTS => 'ALREADY_EXISTS',
39
+ PERMISSION_DENIED => 'PERMISSION_DENIED',
40
+ RESOURCE_EXHAUSTED => 'RESOURCE_EXHAUSTED',
41
+ FAILED_PRECONDITION => 'FAILED_PRECONDITION', ABORTED => 'ABORTED',
42
+ OUT_OF_RANGE => 'OUT_OF_RANGE', UNIMPLEMENTED => 'UNIMPLEMENTED',
43
+ INTERNAL => 'INTERNAL', UNAVAILABLE => 'UNAVAILABLE', DATA_LOSS => 'DATA_LOSS',
44
+ UNAUTHENTICATED => 'UNAUTHENTICATED'
45
+ }.freeze
46
+
47
+ attr_reader :status, :payload, :reason, :content_type
48
+
49
+ def initialize(status = OK, payload = +'', reason = '', content_type = 'application/grpc+proto')
50
+ @status = status
51
+ @payload = payload.b
52
+ @reason = reason
53
+ @content_type = content_type
54
+ end
55
+
56
+ # A successful call carrying `payload` — the framed bytes a client will decode.
57
+ def self.message(payload, content_type = 'application/grpc+proto')
58
+ new(OK, payload, '', content_type)
59
+ end
60
+
61
+ # A successful call with no payload.
62
+ def self.ok(content_type = 'application/grpc+proto')
63
+ new(OK, +'', '', content_type)
64
+ end
65
+
66
+ # A failed call, with `reason` as the sentence a developer will read.
67
+ #
68
+ # `reason` is **not** payload bytes, and there are none: a failed call carries no
69
+ # message. The server percent-encodes the sentence into the `grpc-message` trailer, so
70
+ # it may contain spaces, quotes and newlines.
71
+ def self.fail(status, reason = '')
72
+ new(status, +'', reason)
73
+ end
74
+
75
+ def ok?
76
+ @status == OK
77
+ end
78
+
79
+ def status_name
80
+ NAMES.fetch(@status, '')
81
+ end
82
+ end
83
+ end