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
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.
|
data/lib/volucer/grpc.rb
ADDED
|
@@ -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
|