tonic-rails 0.1.0-x86_64-linux-gnu → 0.3.0-x86_64-linux-gnu
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 +4 -4
- data/CHANGELOG.md +72 -2
- data/README.md +204 -13
- data/lib/generators/tonic_rails/install/install_generator.rb +45 -0
- data/lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt +21 -0
- data/lib/generators/tonic_rails/service/service_generator.rb +110 -0
- data/lib/generators/tonic_rails/service/templates/handler.rb.tt +38 -0
- data/lib/generators/tonic_rails/service/templates/service.proto.tt +16 -0
- data/lib/generators/tonic_rails/service/templates/service_spec.rb.tt +17 -0
- data/lib/generators/tonic_rails/service/templates/service_test.rb.tt +14 -0
- data/lib/puma/plugin/tonic_rails.rb +116 -0
- data/lib/tonic/rails/3.3/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/3.4/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/4.0/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/base.rb +169 -23
- data/lib/tonic/rails/configuration.rb +27 -4
- data/lib/tonic/rails/health.rb +105 -0
- data/lib/tonic/rails/instrumentation.rb +78 -0
- data/lib/tonic/rails/metadata.rb +50 -0
- data/lib/tonic/rails/peer.rb +60 -0
- data/lib/tonic/rails/railtie.rb +2 -2
- data/lib/tonic/rails/router.rb +1 -0
- data/lib/tonic/rails/rpc_error.rb +73 -6
- data/lib/tonic/rails/server.rb +82 -12
- data/lib/tonic/rails/tasks/tonic-rails.rake +13 -1
- data/lib/tonic/rails/testing/errors.rb +47 -0
- data/lib/tonic/rails/testing/minitest.rb +44 -0
- data/lib/tonic/rails/testing/rspec.rb +53 -0
- data/lib/tonic/rails/testing.rb +35 -4
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +4 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b594b6ba6699510bedb286cab5e05fc8fa3578d200fdb704fd8335529ee0105
|
|
4
|
+
data.tar.gz: df3ddc7f65ca300dce59a26855fcadfd88f573c9f892039afa727ec16a3dc01e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d8da9f21bc78c95c31b708a0349d54f23e9a5395bad4de0d46937ec1fae721bd6590a74d498dd523e087b1a1c90b23af6a10c93f2cfaab262d554e3c74272a0
|
|
7
|
+
data.tar.gz: a67219c39168b2eeae48a638b73ebdfa65a4c5a0300138577088f214868db989e36196647639753563584e49dfb256da0175f2b13aac0b8526d30cb9cee38f44
|
data/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,75 @@ minor versions may contain breaking changes.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [0.
|
|
10
|
+
## [0.3.0] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Generators: `rails g tonic_rails:install` (initializer, proto and
|
|
15
|
+
generated-code directories, Puma plugin) and `rails g tonic_rails:service
|
|
16
|
+
Item GetItem ListItems:server` (proto, handler with a stub per RPC,
|
|
17
|
+
registration, and an RSpec or Minitest test).
|
|
18
|
+
- Controller-style callbacks for handlers: `before_rpc`, `after_rpc` and
|
|
19
|
+
`around_rpc` with `only:` / `except:`, plus `current_rpc` and `request`
|
|
20
|
+
accessors.
|
|
21
|
+
- `rescue_from` (ActiveSupport::Rescuable) with a `code:` shorthand that maps
|
|
22
|
+
an exception class to a gRPC status.
|
|
23
|
+
- `peer` in handlers: the client's address and, with mutual TLS, its
|
|
24
|
+
certificate (`peer.certificate`, an `OpenSSL::X509::Certificate`).
|
|
25
|
+
`Testing.call`/`invoke` accept `peer:`.
|
|
26
|
+
- App-controlled health: `config.health_check` (polled every
|
|
27
|
+
`health_check_interval` seconds inside the Rails executor) and
|
|
28
|
+
`Tonic::Rails.server.serving!` / `not_serving!` for the whole server or
|
|
29
|
+
individual services, so readiness probes reflect real dependency health.
|
|
30
|
+
- Test helpers: the `raise_grpc_error(code).with_message(...)` RSpec matcher
|
|
31
|
+
(`require "tonic/rails/testing/rspec"`) and `assert_grpc_error` for Minitest
|
|
32
|
+
(`Tonic::Rails::Testing::Assertions`). Both accept `Tonic::Rails::RpcError`
|
|
33
|
+
and the grpc gem's `GRPC::BadStatus`.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- `tonic_rails:compile_protos` requires protoc 22 or newer and says so,
|
|
38
|
+
instead of generating code google-protobuf 4 can't load (older protoc,
|
|
39
|
+
such as Ubuntu's 3.21 package, emits a Ruby DSL that google-protobuf 4
|
|
40
|
+
removed).
|
|
41
|
+
|
|
42
|
+
## [0.2.0] - 2026-10-11
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- Instrumentation: every RPC publishes a `process_rpc.tonic_rails`
|
|
47
|
+
ActiveSupport::Notifications event (path, service, rpc, metadata, status,
|
|
48
|
+
code) for APM agents, OpenTelemetry and custom subscribers. With no
|
|
49
|
+
subscribers it costs nothing measurable.
|
|
50
|
+
- Response metadata: handlers set `response_metadata` (headers) and
|
|
51
|
+
`trailing_metadata` (trailers, sent on success or failure).
|
|
52
|
+
- Rich errors: `RpcError.new(..., metadata:, details:)` sends trailing
|
|
53
|
+
metadata and a `google.rpc.Status` with packed details in
|
|
54
|
+
`grpc-status-details-bin`, which official gRPC clients decode.
|
|
55
|
+
- Puma plugin: `plugin :tonic_rails` in `config/puma.rb` runs the gRPC
|
|
56
|
+
server as a child process of Puma, in single and cluster mode, with
|
|
57
|
+
graceful stop and restart, and a shutdown if either side dies.
|
|
58
|
+
- Windows: precompiled `x64-mingw-ucrt` gems, tested in CI.
|
|
59
|
+
- `Tonic::Rails::Testing.invoke` returns the response together with its
|
|
60
|
+
headers and trailers.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- Response headers are sent with the handler's first output instead of
|
|
65
|
+
before the handler runs, so handlers can set them. The client deadline
|
|
66
|
+
applies while waiting.
|
|
67
|
+
- `log_requests` logs one line per RPC through the configured (Rails)
|
|
68
|
+
logger instead of stderr.
|
|
69
|
+
- Transport events from the native extension (rejected calls, expired
|
|
70
|
+
deadlines, refused connections, faults) go through the configured (Rails)
|
|
71
|
+
logger with severities instead of stderr. Handler outcomes, such as a
|
|
72
|
+
`NOT_FOUND` raised by a handler, are no longer logged by the transport;
|
|
73
|
+
use `log_requests` or the instrumentation event for those.
|
|
74
|
+
- `Server#stop` signals shutdown through a queue instead of a pipe, so
|
|
75
|
+
signal handling works the same on every platform.
|
|
76
|
+
- Dependencies: magnus 0.9, google-protobuf 4.36.
|
|
77
|
+
|
|
78
|
+
## [0.1.0] - 2026-10-10
|
|
11
79
|
|
|
12
80
|
First public release.
|
|
13
81
|
|
|
@@ -48,5 +116,7 @@ First public release.
|
|
|
48
116
|
- Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
|
|
49
117
|
macOS (x86_64, arm64), Ruby 3.3 to 4.0.
|
|
50
118
|
|
|
51
|
-
[Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.
|
|
119
|
+
[Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.3.0...HEAD
|
|
120
|
+
[0.3.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.2.0...v0.3.0
|
|
121
|
+
[0.2.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...v0.2.0
|
|
52
122
|
[0.1.0]: https://github.com/CodingAnarchy/tonic-rails/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -30,10 +30,11 @@ end
|
|
|
30
30
|
|
|
31
31
|
## Why tonic-rails
|
|
32
32
|
|
|
33
|
-
- **Fast where it counts.**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
- **Fast where it counts.** Inside a Rails app, light RPCs ran at 5–6×
|
|
34
|
+
the throughput of an equivalent Rails JSON endpoint and 1.4–3× the
|
|
35
|
+
official `grpc` gem, and server streaming at 4–7× the `grpc` gem.
|
|
36
|
+
Handlers dominated by slow queries or heavy object building see smaller
|
|
37
|
+
gains. See [PERF.md](PERF.md).
|
|
37
38
|
- **All four RPC kinds.** Unary, server streaming, client streaming and
|
|
38
39
|
bidirectional streaming, with backpressure in both directions.
|
|
39
40
|
- **Production defaults.** Graceful draining on SIGTERM, health checks
|
|
@@ -44,7 +45,7 @@ end
|
|
|
44
45
|
Reflection is off unless you turn it on. TLS problems stop the server
|
|
45
46
|
instead of silently falling back to plaintext.
|
|
46
47
|
- **No Rust toolchain needed** on the platforms we ship precompiled gems
|
|
47
|
-
for
|
|
48
|
+
for: Linux and macOS (x86_64 and arm64) and Windows (x64).
|
|
48
49
|
|
|
49
50
|
## Installation
|
|
50
51
|
|
|
@@ -59,10 +60,31 @@ On platforms without a precompiled gem, installation builds the
|
|
|
59
60
|
extension from source, which needs a [Rust toolchain](https://rustup.rs).
|
|
60
61
|
|
|
61
62
|
To generate Ruby classes from your `.proto` files you also need `protoc`
|
|
62
|
-
(the Protocol Buffers compiler) during development.
|
|
63
|
+
(the Protocol Buffers compiler) **22.0 or newer** during development.
|
|
64
|
+
Older versions, such as Ubuntu's `protobuf-compiler` package (3.21),
|
|
65
|
+
generate code that google-protobuf 4 can't load; install a current
|
|
66
|
+
release from the [protobuf releases page](https://github.com/protocolbuffers/protobuf/releases)
|
|
67
|
+
or Homebrew.
|
|
63
68
|
|
|
64
69
|
## Quick start
|
|
65
70
|
|
|
71
|
+
The generators set everything up:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
bin/rails generate tonic_rails:install
|
|
75
|
+
bin/rails generate tonic_rails:service Item GetItem ListItems:server
|
|
76
|
+
bin/rails tonic_rails:compile_protos
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`install` adds `config/initializers/tonic_rails.rb` (load path for
|
|
80
|
+
generated code, configuration, handler registration), `app/protos/`,
|
|
81
|
+
`lib/grpc/`, and the Puma plugin. `service` writes the `.proto`, a handler
|
|
82
|
+
in `app/grpc/` with a stub per RPC, its registration, and a spec (or
|
|
83
|
+
Minitest test). RPCs take an optional `:server`, `:client` or `:bidi`
|
|
84
|
+
streaming kind.
|
|
85
|
+
|
|
86
|
+
The rest of this section shows the same steps by hand.
|
|
87
|
+
|
|
66
88
|
### 1. Define the service
|
|
67
89
|
|
|
68
90
|
```protobuf
|
|
@@ -139,7 +161,8 @@ bin/rails tonic_rails:routes # lists registered methods
|
|
|
139
161
|
```
|
|
140
162
|
|
|
141
163
|
Run it as its own process (a separate Procfile entry, container or
|
|
142
|
-
Kubernetes deployment) next to your web server
|
|
164
|
+
Kubernetes deployment) next to your web server, or let Puma run it for
|
|
165
|
+
you (see [Running with Puma](#running-with-puma)).
|
|
143
166
|
|
|
144
167
|
The [example app](example/) is a complete, runnable version of all of the
|
|
145
168
|
above.
|
|
@@ -234,11 +257,54 @@ Any other exception becomes `INTERNAL` with the message `internal error`.
|
|
|
234
257
|
The real exception is logged and passed to `Rails.error.report`, so your
|
|
235
258
|
error tracker sees it.
|
|
236
259
|
|
|
260
|
+
### Callbacks and rescue_from
|
|
261
|
+
|
|
262
|
+
Handlers get controller-style callbacks and exception mapping:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
class ItemService
|
|
266
|
+
include Tonic::Rails::Base
|
|
267
|
+
rpc_service "demo.v1.ItemService"
|
|
268
|
+
|
|
269
|
+
before_rpc :authenticate, except: :ListItems
|
|
270
|
+
around_rpc :with_tenant
|
|
271
|
+
after_rpc { StatsD.increment("grpc.#{current_rpc}") }
|
|
272
|
+
|
|
273
|
+
rescue_from ActiveRecord::RecordNotFound, code: :not_found
|
|
274
|
+
rescue_from ActiveRecord::RecordInvalid, code: :invalid_argument, message: ->(e) { e.record.errors.full_messages.join(", ") }
|
|
275
|
+
rescue_from Pundit::NotAuthorizedError, with: :forbidden
|
|
276
|
+
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def authenticate
|
|
280
|
+
@user = User.from_token(grpc_metadata["authorization"])
|
|
281
|
+
raise Tonic::Rails::RpcError.new("invalid token", code: :unauthenticated) unless @user
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def with_tenant(&) = Tenant.switch(@user.tenant, &)
|
|
285
|
+
|
|
286
|
+
def forbidden(_e) = raise(Tonic::Rails::RpcError.new("not allowed", code: :permission_denied))
|
|
287
|
+
end
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
- `before_rpc`, `after_rpc` and `around_rpc` accept method names or a
|
|
291
|
+
block, plus `only:` / `except:` with RPC names (`:GetItem` or
|
|
292
|
+
`:get_item`). Raising in a `before_rpc` stops the call.
|
|
293
|
+
- `current_rpc` is the RPC being handled and `request` the decoded
|
|
294
|
+
request, so callbacks can inspect it.
|
|
295
|
+
- `after_rpc` runs once a streamed response has been fully sent.
|
|
296
|
+
- `rescue_from` covers the handler and its callbacks. With `code:`, the
|
|
297
|
+
exception's message is sent to the client unless you pass `message:`.
|
|
298
|
+
Handlers given with `with:` or a block must raise an `RpcError`.
|
|
299
|
+
Anything unmapped becomes `INTERNAL`, as before.
|
|
300
|
+
|
|
237
301
|
### Metadata, deadlines and shutdown
|
|
238
302
|
|
|
239
303
|
```ruby
|
|
240
304
|
def get_item(request)
|
|
241
305
|
token = grpc_metadata["authorization"] # request headers, lower-case
|
|
306
|
+
peer.ip # client address ("10.0.4.17")
|
|
307
|
+
peer.certificate&.subject # client certificate, with mutual TLS
|
|
242
308
|
deadline_ms # ms left before the client's deadline, or nil
|
|
243
309
|
deadline_exceeded? # true once it has passed
|
|
244
310
|
cancelled? # true while the server shuts down
|
|
@@ -248,6 +314,39 @@ end
|
|
|
248
314
|
When the deadline passes, the client gets `DEADLINE_EXCEEDED` right away;
|
|
249
315
|
check `deadline_exceeded?` in long loops to stop wasted work.
|
|
250
316
|
|
|
317
|
+
### Response metadata
|
|
318
|
+
|
|
319
|
+
Set response headers and trailers from a handler:
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
def get_item(request)
|
|
323
|
+
response_metadata["x-cache"] = "hit" # headers
|
|
324
|
+
trailing_metadata["x-cost-ms"] = "3" # trailers, sent with the status
|
|
325
|
+
response_metadata["x-trace-bin"] = trace_bytes # -bin names take binary values
|
|
326
|
+
Demo::V1::Item.new(id: request.id)
|
|
327
|
+
end
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
For streaming responses, set headers before the first message is sent.
|
|
331
|
+
Trailers are sent whether the call succeeds or fails.
|
|
332
|
+
|
|
333
|
+
### Rich errors
|
|
334
|
+
|
|
335
|
+
`RpcError` can carry trailing metadata and
|
|
336
|
+
[rich error details](https://grpc.io/docs/guides/error/#richer-error-model):
|
|
337
|
+
protobuf messages packed into a `google.rpc.Status` in the standard
|
|
338
|
+
`grpc-status-details-bin` trailer, which official gRPC clients decode for
|
|
339
|
+
you.
|
|
340
|
+
|
|
341
|
+
```ruby
|
|
342
|
+
raise Tonic::Rails::RpcError.new(
|
|
343
|
+
"invalid item",
|
|
344
|
+
code: :invalid_argument,
|
|
345
|
+
metadata: { "x-request-id" => request_id },
|
|
346
|
+
details: [Google::Rpc::BadRequest.new(field_violations: violations)],
|
|
347
|
+
)
|
|
348
|
+
```
|
|
349
|
+
|
|
251
350
|
### Interceptors
|
|
252
351
|
|
|
253
352
|
Interceptors wrap every call, outermost first. Call `next_call` to
|
|
@@ -280,6 +379,35 @@ expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
|
|
|
280
379
|
|
|
281
380
|
# Streaming: pass an Array of requests, get an Array of responses
|
|
282
381
|
Tonic::Rails::Testing.call(ItemService, :ImportItems, [{ name: "a" }, { name: "b" }])
|
|
382
|
+
|
|
383
|
+
# Response metadata
|
|
384
|
+
result = Tonic::Rails::Testing.invoke(ItemService, :GetItem, { id: 1 })
|
|
385
|
+
result.headers["x-cache"] # => "hit"
|
|
386
|
+
result.trailers["x-cost-ms"] # => "3"
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Matchers for gRPC statuses work with `Testing.call` and with the official
|
|
390
|
+
`grpc` client's errors:
|
|
391
|
+
|
|
392
|
+
```ruby
|
|
393
|
+
# spec/rails_helper.rb
|
|
394
|
+
require "tonic/rails/testing/rspec"
|
|
395
|
+
|
|
396
|
+
expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
|
|
397
|
+
.to raise_grpc_error(:not_found).with_message(/0/)
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
# Minitest
|
|
402
|
+
require "tonic/rails/testing/minitest"
|
|
403
|
+
|
|
404
|
+
class ItemServiceTest < ActiveSupport::TestCase
|
|
405
|
+
include Tonic::Rails::Testing::Assertions
|
|
406
|
+
|
|
407
|
+
test "missing items" do
|
|
408
|
+
assert_grpc_error(:not_found) { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
|
|
409
|
+
end
|
|
410
|
+
end
|
|
283
411
|
```
|
|
284
412
|
|
|
285
413
|
## Configuration
|
|
@@ -313,11 +441,13 @@ end
|
|
|
313
441
|
| `tls_client_ca_path` | `nil` | PEM CA bundle; requires client certificates (mutual TLS) |
|
|
314
442
|
| `drain_timeout` | `10` | Seconds to let in-flight calls finish on shutdown |
|
|
315
443
|
| `keepalive_interval`, `keepalive_timeout` | `15`, `5` | HTTP/2 PING interval and timeout, seconds |
|
|
444
|
+
| `health_check` | `nil` | Callable; while false or raising, health reports `NOT_SERVING` |
|
|
445
|
+
| `health_check_interval` | `5` | Seconds between health checks |
|
|
316
446
|
| `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
|
|
317
447
|
| `reflection` | `false` | Serve `grpc.reflection.v1`; exposes your schema |
|
|
318
448
|
| `interceptors` | `[]` | See [Interceptors](#interceptors) |
|
|
319
|
-
| `log_requests` | `false` | Log
|
|
320
|
-
| `auto_start` | `false` | Start
|
|
449
|
+
| `log_requests` | `false` | Log one line per call through `logger` (unexpected exceptions are always logged) |
|
|
450
|
+
| `auto_start` | `false` | Start in a thread of `rails server`; for development (see [auto_start](#auto_start)) |
|
|
321
451
|
|
|
322
452
|
### Sizing threads
|
|
323
453
|
|
|
@@ -328,31 +458,92 @@ concurrency your I/O needs, size the database pool to match, and add
|
|
|
328
458
|
processes for CPU-heavy services: the listener uses `SO_REUSEPORT`, so
|
|
329
459
|
several processes can bind the same port.
|
|
330
460
|
|
|
461
|
+
### Running with Puma
|
|
462
|
+
|
|
463
|
+
Add the plugin to `config/puma.rb`:
|
|
464
|
+
|
|
465
|
+
```ruby
|
|
466
|
+
plugin :tonic_rails
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Puma then runs the gRPC server as one child process, in single and cluster
|
|
470
|
+
mode alike, so gRPC gets its own process and GVL instead of competing with
|
|
471
|
+
web workers. It starts after Puma boots and drains gracefully when Puma
|
|
472
|
+
stops or restarts. If the gRPC process dies, Puma shuts down too, so your
|
|
473
|
+
supervisor restarts both. It works with `rails server`, plain `puma`, and
|
|
474
|
+
`preload_app!`. (Not on Windows, which has no `fork()`: run
|
|
475
|
+
`tonic_rails:serve` as its own process there.)
|
|
476
|
+
|
|
477
|
+
For larger deployments, a dedicated `bin/rails tonic_rails:serve` process
|
|
478
|
+
(scaled separately) is still the most flexible option.
|
|
479
|
+
|
|
331
480
|
### auto_start
|
|
332
481
|
|
|
333
482
|
`auto_start = true` starts the gRPC server in a background thread of
|
|
334
483
|
`rails server` (never in consoles, rake tasks or tests). It's handy in
|
|
335
|
-
development
|
|
336
|
-
|
|
337
|
-
start one per worker.
|
|
484
|
+
development; in production use the Puma plugin or a dedicated process.
|
|
485
|
+
Don't combine it with the plugin.
|
|
338
486
|
|
|
339
487
|
## Operations
|
|
340
488
|
|
|
341
489
|
- **Health checks.** `grpc.health.v1.Health` reports `SERVING` for the
|
|
342
490
|
server (`""`) and every registered service, and flips to `NOT_SERVING`
|
|
343
491
|
as soon as shutdown begins. Point your load balancer or Kubernetes
|
|
344
|
-
gRPC probes at it.
|
|
492
|
+
gRPC probes at it. To make readiness reflect your dependencies, add a
|
|
493
|
+
check that runs every `health_check_interval` seconds (default 5):
|
|
494
|
+
|
|
495
|
+
```ruby
|
|
496
|
+
config.health_check = -> { ActiveRecord::Base.connection.active? }
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
While it returns false or raises, the server reports `NOT_SERVING`
|
|
500
|
+
(calls are still served). You can also mark the server or a service
|
|
501
|
+
yourself, for example during maintenance:
|
|
502
|
+
`Tonic::Rails.server.not_serving!("demo.v1.ItemService")` and
|
|
503
|
+
`serving!`. A service is `SERVING` only while the check passes and it
|
|
504
|
+
isn't marked otherwise.
|
|
345
505
|
- **Graceful shutdown.** On SIGTERM or SIGINT the server stops
|
|
346
506
|
accepting connections, tells clients to go away (HTTP/2 GOAWAY), lets
|
|
347
507
|
in-flight calls finish for up to `drain_timeout` seconds, then exits.
|
|
348
508
|
- **Load shedding.** When every dispatch thread is busy and the queue is
|
|
349
509
|
full, new calls fail fast with `RESOURCE_EXHAUSTED` instead of piling up.
|
|
510
|
+
- **Instrumentation.** Every RPC publishes a `process_rpc.tonic_rails`
|
|
511
|
+
event through `ActiveSupport::Notifications`, so APM agents,
|
|
512
|
+
OpenTelemetry and your own subscribers can trace calls. See
|
|
513
|
+
[Instrumentation](#instrumentation).
|
|
514
|
+
- **Logs.** Everything goes through your (Rails) logger. With
|
|
515
|
+
`log_requests = true`, each RPC logs one line, e.g.
|
|
516
|
+
`gRPC /demo.v1.ItemService/GetItem OK in 1.4ms`. Calls the transport
|
|
517
|
+
rejects before reaching Ruby (unknown methods, a full queue, expired
|
|
518
|
+
deadlines, oversized messages, refused connections) are always logged,
|
|
519
|
+
at info for client-caused problems, warn for overload and error for faults.
|
|
350
520
|
- **Stats.** `Tonic::Rails.server.stats` returns counters for totals,
|
|
351
521
|
successes, errors, cancellations, deadline and resource errors, active
|
|
352
522
|
connections and queued calls.
|
|
353
523
|
- **Reflection.** Turn on `reflection` in development to use
|
|
354
524
|
[grpcurl](https://github.com/fullstorydev/grpcurl) without proto files.
|
|
355
525
|
|
|
526
|
+
## Instrumentation
|
|
527
|
+
|
|
528
|
+
Every RPC is wrapped in an `ActiveSupport::Notifications` event, inside the
|
|
529
|
+
Rails executor, so subscribers see the same context as your handler:
|
|
530
|
+
|
|
531
|
+
```ruby
|
|
532
|
+
ActiveSupport::Notifications.subscribe("process_rpc.tonic_rails") do |event|
|
|
533
|
+
event.payload[:path] # "/demo.v1.ItemService/GetItem"
|
|
534
|
+
event.payload[:service] # "demo.v1.ItemService"
|
|
535
|
+
event.payload[:rpc] # "GetItem"
|
|
536
|
+
event.payload[:metadata] # request metadata
|
|
537
|
+
event.payload[:status] # :ok, :not_found, :internal, ...
|
|
538
|
+
event.payload[:code] # numeric gRPC status
|
|
539
|
+
event.duration # milliseconds
|
|
540
|
+
end
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
When a call fails, `event.payload[:exception_object]` is the
|
|
544
|
+
`Tonic::Rails::RpcError` sent to the client. If a handler raised something
|
|
545
|
+
unexpected, that error's `cause` is the original exception.
|
|
546
|
+
|
|
356
547
|
## How it works
|
|
357
548
|
|
|
358
549
|
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module TonicRails
|
|
6
|
+
module Generators
|
|
7
|
+
# rails generate tonic_rails:install
|
|
8
|
+
#
|
|
9
|
+
# Sets an app up for gRPC: an initializer (load path for generated
|
|
10
|
+
# protobuf code, configuration, handler registration), the proto and
|
|
11
|
+
# generated-code directories, and the Puma plugin.
|
|
12
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
class_option :skip_puma, type: :boolean, default: false,
|
|
16
|
+
desc: "Don't add `plugin :tonic_rails` to config/puma.rb"
|
|
17
|
+
class_option :skip_next_steps, type: :boolean, default: false, hide: true
|
|
18
|
+
|
|
19
|
+
def create_initializer
|
|
20
|
+
template "tonic_rails.rb.tt", "config/initializers/tonic_rails.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_directories
|
|
24
|
+
create_file "app/protos/.keep"
|
|
25
|
+
create_file "lib/grpc/.keep"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def add_puma_plugin
|
|
29
|
+
return if options[:skip_puma]
|
|
30
|
+
|
|
31
|
+
puma = "config/puma.rb"
|
|
32
|
+
return unless File.exist?(File.join(destination_root, puma))
|
|
33
|
+
return if File.read(File.join(destination_root, puma)).match?(/plugin\s+:tonic_rails/)
|
|
34
|
+
|
|
35
|
+
append_to_file puma, "\n# Run the tonic-rails gRPC server alongside Puma.\nplugin :tonic_rails\n"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def show_next_steps
|
|
39
|
+
return if options[:skip_next_steps]
|
|
40
|
+
|
|
41
|
+
say "\nNext: rails generate tonic_rails:service Item GetItem ListItems:server", :green
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ruby code generated from app/protos by `bin/rails tonic_rails:compile_protos`
|
|
4
|
+
# lives in lib/grpc. Its files require each other by plain name, so put the
|
|
5
|
+
# directory on the load path and keep Zeitwerk away from it.
|
|
6
|
+
Rails.root.join("lib/grpc").then do |generated|
|
|
7
|
+
$LOAD_PATH.unshift(generated.to_s) unless $LOAD_PATH.include?(generated.to_s)
|
|
8
|
+
Rails.autoloaders.main.ignore(generated)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
Tonic::Rails.configure do |config|
|
|
12
|
+
# config.bind_address = "0.0.0.0:50051"
|
|
13
|
+
# config.dispatch_threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
|
|
14
|
+
# config.reflection = Rails.env.development?
|
|
15
|
+
# config.health_check = -> { ActiveRecord::Base.connection.active? }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Handlers in app/grpc are autoloaded; register them here so they're
|
|
19
|
+
# picked up again after code reloads.
|
|
20
|
+
Rails.application.config.to_prepare do
|
|
21
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module TonicRails
|
|
6
|
+
module Generators
|
|
7
|
+
# rails generate tonic_rails:service Item GetItem ListItems:server ImportItems:client Sync:bidi
|
|
8
|
+
#
|
|
9
|
+
# Creates a .proto with the service and its messages, a handler with a
|
|
10
|
+
# method per RPC, the handler's registration, and a test.
|
|
11
|
+
class ServiceGenerator < ::Rails::Generators::NamedBase
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
STREAM_KINDS = %w[unary server client bidi].freeze
|
|
15
|
+
|
|
16
|
+
argument :rpcs, type: :array, default: [], banner: "Rpc[:server|:client|:bidi] ..."
|
|
17
|
+
|
|
18
|
+
class_option :package, type: :string,
|
|
19
|
+
desc: "Protobuf package (default: <app name>.v1)"
|
|
20
|
+
|
|
21
|
+
def check_rpcs
|
|
22
|
+
rpc_specs.each do |rpc|
|
|
23
|
+
next if rpc[:name].match?(/\A[A-Z][A-Za-z0-9]*\z/) && STREAM_KINDS.include?(rpc[:kind])
|
|
24
|
+
|
|
25
|
+
raise Thor::Error, "Invalid RPC #{rpc[:raw].inspect}: use CamelCase names, optionally :server, :client or :bidi"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create_proto
|
|
30
|
+
template "service.proto.tt", "app/protos/#{package_path}/#{service_file}_service.proto"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create_handler
|
|
34
|
+
template "handler.rb.tt", "app/grpc/#{service_file}_service.rb"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def register_handler
|
|
38
|
+
initializer = "config/initializers/tonic_rails.rb"
|
|
39
|
+
invoke "tonic_rails:install", [], skip_next_steps: true unless File.exist?(File.join(destination_root, initializer))
|
|
40
|
+
inject_into_file initializer, " Tonic::Rails.server.register(#{service_class})\n",
|
|
41
|
+
after: "Rails.application.config.to_prepare do\n"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def create_test
|
|
45
|
+
if File.directory?(File.join(destination_root, "spec"))
|
|
46
|
+
template "service_spec.rb.tt", "spec/grpc/#{service_file}_service_spec.rb"
|
|
47
|
+
else
|
|
48
|
+
template "service_test.rb.tt", "test/grpc/#{service_file}_service_test.rb"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def show_next_steps
|
|
53
|
+
say "\nNext: bin/rails tonic_rails:compile_protos, then fill in app/grpc/#{service_file}_service.rb", :green
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# "Item" and "ItemService" both mean the service ItemService.
|
|
59
|
+
def service_base
|
|
60
|
+
class_name.delete_suffix("Service")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def service_file
|
|
64
|
+
service_base.underscore
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def service_class
|
|
68
|
+
"#{service_base}Service"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def package
|
|
72
|
+
options[:package] || "#{app_name}.v1"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def app_name
|
|
76
|
+
app = defined?(::Rails.application) && ::Rails.application
|
|
77
|
+
name = app ? app.class.module_parent_name : File.basename(destination_root)
|
|
78
|
+
name.to_s.underscore.tr("-", "_").presence || "app"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def package_path
|
|
82
|
+
package.tr(".", "/")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ruby_module
|
|
86
|
+
package.split(".").map(&:camelize).join("::")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def full_service_name
|
|
90
|
+
"#{package}.#{service_class}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def rpc_specs
|
|
94
|
+
list = rpcs.empty? ? ["Get#{service_base}"] : rpcs
|
|
95
|
+
list.map do |raw|
|
|
96
|
+
name, kind = raw.split(":", 2)
|
|
97
|
+
{ raw: raw, name: name.to_s, kind: kind || "unary", method: name.to_s.underscore }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def proto_rpc_line(rpc)
|
|
102
|
+
request = "#{rpc[:name]}Request"
|
|
103
|
+
response = "#{rpc[:name]}Response"
|
|
104
|
+
request = "stream #{request}" if %w[client bidi].include?(rpc[:kind])
|
|
105
|
+
response = "stream #{response}" if %w[server bidi].include?(rpc[:kind])
|
|
106
|
+
"rpc #{rpc[:name]}(#{request}) returns (#{response});"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "<%= package_path %>/<%= service_file %>_service_pb"
|
|
4
|
+
|
|
5
|
+
class <%= service_class %>
|
|
6
|
+
include Tonic::Rails::Base
|
|
7
|
+
|
|
8
|
+
rpc_service "<%= full_service_name %>"
|
|
9
|
+
request_format :message
|
|
10
|
+
<% rpc_specs.each do |rpc| %>
|
|
11
|
+
<% case rpc[:kind] -%>
|
|
12
|
+
<% when "unary" -%>
|
|
13
|
+
# @param request [<%= ruby_module %>::<%= rpc[:name] %>Request]
|
|
14
|
+
# @return [<%= ruby_module %>::<%= rpc[:name] %>Response]
|
|
15
|
+
def <%= rpc[:method] %>(request)
|
|
16
|
+
raise Tonic::Rails::RpcError.new("<%= rpc[:name] %> is not implemented yet", code: :unimplemented)
|
|
17
|
+
end
|
|
18
|
+
<% when "server" -%>
|
|
19
|
+
# @param request [<%= ruby_module %>::<%= rpc[:name] %>Request]
|
|
20
|
+
# @return [Enumerable<<%= ruby_module %>::<%= rpc[:name] %>Response>] each element is streamed as it's produced
|
|
21
|
+
def <%= rpc[:method] %>(request)
|
|
22
|
+
raise Tonic::Rails::RpcError.new("<%= rpc[:name] %> is not implemented yet", code: :unimplemented)
|
|
23
|
+
end
|
|
24
|
+
<% when "client" -%>
|
|
25
|
+
# @param requests [Enumerator<<%= ruby_module %>::<%= rpc[:name] %>Request>] yields messages as they arrive
|
|
26
|
+
# @return [<%= ruby_module %>::<%= rpc[:name] %>Response]
|
|
27
|
+
def <%= rpc[:method] %>(requests)
|
|
28
|
+
raise Tonic::Rails::RpcError.new("<%= rpc[:name] %> is not implemented yet", code: :unimplemented)
|
|
29
|
+
end
|
|
30
|
+
<% else -%>
|
|
31
|
+
# @param requests [Enumerator<<%= ruby_module %>::<%= rpc[:name] %>Request>] yields messages as they arrive
|
|
32
|
+
# @return [Enumerable<<%= ruby_module %>::<%= rpc[:name] %>Response>]
|
|
33
|
+
def <%= rpc[:method] %>(requests)
|
|
34
|
+
raise Tonic::Rails::RpcError.new("<%= rpc[:name] %> is not implemented yet", code: :unimplemented)
|
|
35
|
+
end
|
|
36
|
+
<% end -%>
|
|
37
|
+
<% end -%>
|
|
38
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
syntax = "proto3";
|
|
2
|
+
|
|
3
|
+
package <%= package %>;
|
|
4
|
+
|
|
5
|
+
service <%= service_class %> {
|
|
6
|
+
<% rpc_specs.each do |rpc| -%>
|
|
7
|
+
<%= proto_rpc_line(rpc) %>
|
|
8
|
+
<% end -%>
|
|
9
|
+
}
|
|
10
|
+
<% rpc_specs.each do |rpc| %>
|
|
11
|
+
message <%= rpc[:name] %>Request {
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
message <%= rpc[:name] %>Response {
|
|
15
|
+
}
|
|
16
|
+
<% end -%>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_helper"
|
|
4
|
+
require "tonic/rails/testing/rspec"
|
|
5
|
+
|
|
6
|
+
RSpec.describe <%= service_class %> do
|
|
7
|
+
<% rpc_specs.each_with_index do |rpc, i| -%>
|
|
8
|
+
<% if i.positive? %>
|
|
9
|
+
<% end -%>
|
|
10
|
+
describe "<%= rpc[:name] %>" do
|
|
11
|
+
it "is not implemented yet" do
|
|
12
|
+
request = <%= %w[client bidi].include?(rpc[:kind]) ? "[#{ruby_module}::#{rpc[:name]}Request.new]" : "#{ruby_module}::#{rpc[:name]}Request.new" %>
|
|
13
|
+
expect { Tonic::Rails::Testing.call(described_class, :<%= rpc[:name] %>, request) }.to raise_grpc_error(:unimplemented)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
<% end -%>
|
|
17
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "test_helper"
|
|
4
|
+
require "tonic/rails/testing/minitest"
|
|
5
|
+
|
|
6
|
+
class <%= service_class %>Test < ActiveSupport::TestCase
|
|
7
|
+
include Tonic::Rails::Testing::Assertions
|
|
8
|
+
<% rpc_specs.each do |rpc| %>
|
|
9
|
+
test "<%= rpc[:name] %> is not implemented yet" do
|
|
10
|
+
request = <%= %w[client bidi].include?(rpc[:kind]) ? "[#{ruby_module}::#{rpc[:name]}Request.new]" : "#{ruby_module}::#{rpc[:name]}Request.new" %>
|
|
11
|
+
assert_grpc_error(:unimplemented) { Tonic::Rails::Testing.call(<%= service_class %>, :<%= rpc[:name] %>, request) }
|
|
12
|
+
end
|
|
13
|
+
<% end -%>
|
|
14
|
+
end
|