tonic-rails 0.2.0-x64-mingw-ucrt
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/CHANGELOG.md +89 -0
- data/LICENSE +21 -0
- data/README.md +480 -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 +262 -0
- data/lib/tonic/rails/configuration.rb +150 -0
- data/lib/tonic/rails/instrumentation.rb +78 -0
- data/lib/tonic/rails/metadata.rb +50 -0
- data/lib/tonic/rails/railtie.rb +56 -0
- data/lib/tonic/rails/reflection.rb +50 -0
- data/lib/tonic/rails/router.rb +71 -0
- data/lib/tonic/rails/rpc_error.rb +132 -0
- data/lib/tonic/rails/server.rb +271 -0
- data/lib/tonic/rails/tasks/tonic-rails.rake +36 -0
- data/lib/tonic/rails/testing.rb +114 -0
- data/lib/tonic/rails/version.rb +7 -0
- data/lib/tonic-rails.rb +93 -0
- metadata +107 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 175412704ded2402eae6f84cf04a27725c546fb51e119bd58ffd3799334a2468
|
|
4
|
+
data.tar.gz: a23d20ff4e88be9ad633d91cd3720922bac33f265f7140fd9c582c7ccd04e158
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8e573f7a4bd52e45f8b4a1e8b7e0690873c2ed08f840df5b9e3d7a81a5016440d7a69c4c202c14069fb9e3e29f4394fd41d938ebdf855ab132e10152f5808627
|
|
7
|
+
data.tar.gz: 1dbe6fa96e87f4801737dea5cf674f6646490731d8ea7c3684eca8b325161edabd0e5287157280095809cc1f5cd595d8a68815501132c9d8d2424e9dfb91d42b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
|
|
5
|
+
project follows [Semantic Versioning](https://semver.org/). Until 1.0,
|
|
6
|
+
minor versions may contain breaking changes.
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.2.0] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Instrumentation: every RPC publishes a `process_rpc.tonic_rails`
|
|
15
|
+
ActiveSupport::Notifications event (path, service, rpc, metadata, status,
|
|
16
|
+
code) for APM agents, OpenTelemetry and custom subscribers. With no
|
|
17
|
+
subscribers it costs nothing measurable.
|
|
18
|
+
- Response metadata: handlers set `response_metadata` (headers) and
|
|
19
|
+
`trailing_metadata` (trailers, sent on success or failure).
|
|
20
|
+
- Rich errors: `RpcError.new(..., metadata:, details:)` sends trailing
|
|
21
|
+
metadata and a `google.rpc.Status` with packed details in
|
|
22
|
+
`grpc-status-details-bin`, which official gRPC clients decode.
|
|
23
|
+
- Puma plugin: `plugin :tonic_rails` in `config/puma.rb` runs the gRPC
|
|
24
|
+
server as a child process of Puma, in single and cluster mode, with
|
|
25
|
+
graceful stop and restart, and a shutdown if either side dies.
|
|
26
|
+
- Windows: precompiled `x64-mingw-ucrt` gems, tested in CI.
|
|
27
|
+
- `Tonic::Rails::Testing.invoke` returns the response together with its
|
|
28
|
+
headers and trailers.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- Response headers are sent with the handler's first output instead of
|
|
33
|
+
before the handler runs, so handlers can set them. The client deadline
|
|
34
|
+
applies while waiting.
|
|
35
|
+
- `log_requests` logs one line per RPC through the configured (Rails)
|
|
36
|
+
logger instead of stderr.
|
|
37
|
+
- Transport events from the native extension (rejected calls, expired
|
|
38
|
+
deadlines, refused connections, faults) go through the configured (Rails)
|
|
39
|
+
logger with severities instead of stderr. Handler outcomes, such as a
|
|
40
|
+
`NOT_FOUND` raised by a handler, are no longer logged by the transport;
|
|
41
|
+
use `log_requests` or the instrumentation event for those.
|
|
42
|
+
- `Server#stop` signals shutdown through a queue instead of a pipe, so
|
|
43
|
+
signal handling works the same on every platform.
|
|
44
|
+
- Dependencies: magnus 0.9, google-protobuf 4.36.
|
|
45
|
+
|
|
46
|
+
## [0.1.0] - 2026-10-10
|
|
47
|
+
|
|
48
|
+
First public release.
|
|
49
|
+
|
|
50
|
+
### Serving
|
|
51
|
+
|
|
52
|
+
- gRPC server for Rails built on tonic 0.14 (HTTP/2, TLS via rustls,
|
|
53
|
+
keepalive, flow control), with Ruby handlers running on a pool of
|
|
54
|
+
dispatch threads inside the Rails executor.
|
|
55
|
+
- Unary, server-streaming, client-streaming and bidirectional RPCs, with
|
|
56
|
+
backpressure in both directions and incremental streaming.
|
|
57
|
+
- Handlers declared with `rpc_service` (read from the protobuf descriptor
|
|
58
|
+
pool) or individual `rpc` calls; requests as Hashes or, with
|
|
59
|
+
`request_format :message`, protobuf messages; responses as either.
|
|
60
|
+
- Routes registered at runtime: no service definitions are compiled into
|
|
61
|
+
the native extension.
|
|
62
|
+
- Built-in `grpc.health.v1` (per service, flips to NOT_SERVING on
|
|
63
|
+
shutdown) and opt-in `grpc.reflection.v1` generated from loaded
|
|
64
|
+
descriptors.
|
|
65
|
+
- Interceptors, request metadata, client deadlines (`deadline_ms`,
|
|
66
|
+
`deadline_exceeded?`) and shutdown awareness (`cancelled?`).
|
|
67
|
+
- `Tonic::Rails::Testing.call` for in-process handler tests.
|
|
68
|
+
- Railtie: `config.tonic_rails.*` settings, Rails logger and error
|
|
69
|
+
reporter, code reloading support, rake tasks `tonic_rails:serve`,
|
|
70
|
+
`tonic_rails:routes` and `tonic_rails:compile_protos`.
|
|
71
|
+
|
|
72
|
+
### Production behaviour
|
|
73
|
+
|
|
74
|
+
- Graceful shutdown on SIGTERM/SIGINT: health flips to NOT_SERVING, new
|
|
75
|
+
connections are refused, in-flight calls drain for `drain_timeout`.
|
|
76
|
+
- Limits: `max_message_size` (also caps gzip-decompressed size),
|
|
77
|
+
`max_connections`, `concurrency_limit_per_connection`, request metadata
|
|
78
|
+
size, and `dispatch_queue_size` load shedding (`RESOURCE_EXHAUSTED`).
|
|
79
|
+
- TLS and mutual TLS (`tls_client_ca_path`). Invalid TLS configuration
|
|
80
|
+
stops startup; the server never falls back to plaintext.
|
|
81
|
+
- Unexpected exceptions (in handlers or interceptors) reach clients only
|
|
82
|
+
as `INTERNAL: internal error`; details are logged and reported.
|
|
83
|
+
- `grpc-message` is percent-encoded per the gRPC spec.
|
|
84
|
+
- Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
|
|
85
|
+
macOS (x86_64, arm64), Ruby 3.3 to 4.0.
|
|
86
|
+
|
|
87
|
+
[Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.2.0...HEAD
|
|
88
|
+
[0.2.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...v0.2.0
|
|
89
|
+
[0.1.0]: https://github.com/CodingAnarchy/tonic-rails/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matt Tanous
|
|
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,480 @@
|
|
|
1
|
+
# tonic-rails
|
|
2
|
+
|
|
3
|
+
[](https://github.com/CodingAnarchy/tonic-rails/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/tonic-rails)
|
|
5
|
+
|
|
6
|
+
Serve gRPC from your Rails app, with the transport handled by
|
|
7
|
+
[tonic], Rust's production gRPC stack.
|
|
8
|
+
|
|
9
|
+
Think of it like Puma for gRPC. Puma handles sockets and HTTP so your
|
|
10
|
+
controllers only see requests. tonic-rails does the same for gRPC:
|
|
11
|
+
HTTP/2, TLS, flow control, keepalives, health checks and reflection all
|
|
12
|
+
run in Rust, outside the GVL. Your handlers are plain Ruby classes that
|
|
13
|
+
receive protobuf messages and return them, inside the Rails executor,
|
|
14
|
+
with your models, caches and error reporting all available.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
class ItemService
|
|
18
|
+
include Tonic::Rails::Base
|
|
19
|
+
rpc_service "demo.v1.ItemService"
|
|
20
|
+
request_format :message
|
|
21
|
+
|
|
22
|
+
def get_item(request)
|
|
23
|
+
item = Item.find_by(id: request.id)
|
|
24
|
+
raise Tonic::Rails::RpcError.new("item not found", code: :not_found) unless item
|
|
25
|
+
|
|
26
|
+
Demo::V1::Item.new(id: item.id, name: item.name)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Why tonic-rails
|
|
32
|
+
|
|
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).
|
|
38
|
+
- **All four RPC kinds.** Unary, server streaming, client streaming and
|
|
39
|
+
bidirectional streaming, with backpressure in both directions.
|
|
40
|
+
- **Production defaults.** Graceful draining on SIGTERM, health checks
|
|
41
|
+
that flip to NOT_SERVING during shutdown, deadlines, message size and
|
|
42
|
+
connection limits, load shedding, gzip, TLS and mutual TLS.
|
|
43
|
+
- **Safe by default.** Unexpected exceptions reach clients as a generic
|
|
44
|
+
INTERNAL error (the details go to your logs and `Rails.error`).
|
|
45
|
+
Reflection is off unless you turn it on. TLS problems stop the server
|
|
46
|
+
instead of silently falling back to plaintext.
|
|
47
|
+
- **No Rust toolchain needed** on the platforms we ship precompiled gems
|
|
48
|
+
for: Linux and macOS (x86_64 and arm64) and Windows (x64).
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# Gemfile
|
|
54
|
+
gem "tonic-rails"
|
|
55
|
+
gem "google-protobuf"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Requires Ruby 3.3+ and Rails 7.1+ (or just ActiveSupport, outside Rails).
|
|
59
|
+
On platforms without a precompiled gem, installation builds the
|
|
60
|
+
extension from source, which needs a [Rust toolchain](https://rustup.rs).
|
|
61
|
+
|
|
62
|
+
To generate Ruby classes from your `.proto` files you also need `protoc`
|
|
63
|
+
(the Protocol Buffers compiler) during development.
|
|
64
|
+
|
|
65
|
+
## Quick start
|
|
66
|
+
|
|
67
|
+
### 1. Define the service
|
|
68
|
+
|
|
69
|
+
```protobuf
|
|
70
|
+
// app/protos/items.proto
|
|
71
|
+
syntax = "proto3";
|
|
72
|
+
package demo.v1;
|
|
73
|
+
|
|
74
|
+
message Item { int64 id = 1; string name = 2; }
|
|
75
|
+
message GetItemRequest { int64 id = 1; }
|
|
76
|
+
|
|
77
|
+
service ItemService {
|
|
78
|
+
rpc GetItem(GetItemRequest) returns (Item);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Generate the Ruby classes:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
bin/rails tonic_rails:compile_protos # app/protos → lib/grpc
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Generated files `require` each other by name, so keep them out of the
|
|
89
|
+
autoload paths and put their directory on the load path:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
# config/application.rb
|
|
93
|
+
$LOAD_PATH.unshift(File.expand_path("../lib/grpc", __dir__))
|
|
94
|
+
|
|
95
|
+
module MyApp
|
|
96
|
+
class Application < Rails::Application
|
|
97
|
+
config.autoload_lib(ignore: %w[grpc])
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 2. Write a handler
|
|
103
|
+
|
|
104
|
+
Handlers live anywhere Rails autoloads from; `app/grpc` is a good home.
|
|
105
|
+
Each RPC maps to a snake_case method.
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# app/grpc/item_service.rb
|
|
109
|
+
require "items_pb"
|
|
110
|
+
|
|
111
|
+
class ItemService
|
|
112
|
+
include Tonic::Rails::Base
|
|
113
|
+
|
|
114
|
+
rpc_service "demo.v1.ItemService" # every RPC, read from the descriptor
|
|
115
|
+
request_format :message # receive protobuf messages
|
|
116
|
+
|
|
117
|
+
def get_item(request)
|
|
118
|
+
item = Item.find(request.id)
|
|
119
|
+
Demo::V1::Item.new(id: item.id, name: item.name)
|
|
120
|
+
rescue ActiveRecord::RecordNotFound
|
|
121
|
+
raise Tonic::Rails::RpcError.new("item #{request.id} not found", code: :not_found)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 3. Register and run
|
|
127
|
+
|
|
128
|
+
Register handlers in `to_prepare` so they work with code reloading:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# config/initializers/tonic_rails.rb
|
|
132
|
+
Rails.application.config.to_prepare do
|
|
133
|
+
Tonic::Rails.server.register(ItemService)
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
bin/rails tonic_rails:serve # serves on 0.0.0.0:50051
|
|
139
|
+
bin/rails tonic_rails:routes # lists registered methods
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Run it as its own process (a separate Procfile entry, container or
|
|
143
|
+
Kubernetes deployment) next to your web server, or let Puma run it for
|
|
144
|
+
you (see [Running with Puma](#running-with-puma)).
|
|
145
|
+
|
|
146
|
+
The [example app](example/) is a complete, runnable version of all of the
|
|
147
|
+
above.
|
|
148
|
+
|
|
149
|
+
## Handlers
|
|
150
|
+
|
|
151
|
+
### Requests and responses
|
|
152
|
+
|
|
153
|
+
By default handlers receive a string-keyed Hash. With
|
|
154
|
+
`request_format :message` they receive the decoded protobuf message
|
|
155
|
+
instead, which is faster and type-safe. Either way, return a protobuf
|
|
156
|
+
message or a Hash:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
def get_item(request)
|
|
160
|
+
{ "id" => request.id, "name" => "Widget" } # a Hash works too
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
A fresh handler instance serves each call, so instance variables never
|
|
165
|
+
leak between requests.
|
|
166
|
+
|
|
167
|
+
### Declaring RPCs
|
|
168
|
+
|
|
169
|
+
`rpc_service "package.Service"` reads every RPC (and its streaming kind)
|
|
170
|
+
from the generated descriptor. To declare RPCs one at a time instead:
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
class ItemService
|
|
174
|
+
include Tonic::Rails::Base
|
|
175
|
+
service "demo.v1.ItemService"
|
|
176
|
+
|
|
177
|
+
rpc :GetItem, Demo::V1::GetItemRequest, Demo::V1::Item
|
|
178
|
+
rpc :ListItems, Demo::V1::ListItemsRequest, Demo::V1::Item, stream: :server
|
|
179
|
+
end
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
RPCs declared without a handler method answer `UNIMPLEMENTED`.
|
|
183
|
+
|
|
184
|
+
### Streaming
|
|
185
|
+
|
|
186
|
+
Server streaming: return any Enumerable. Messages go out as you produce
|
|
187
|
+
them, and if the client reads slowly your handler waits (without holding
|
|
188
|
+
the GVL) instead of buffering everything in memory.
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
def list_items(request)
|
|
192
|
+
Enumerator.new do |stream|
|
|
193
|
+
Item.where(tag: request.tag).find_each do |item|
|
|
194
|
+
break if cancelled? # the server is shutting down
|
|
195
|
+
stream << Demo::V1::Item.new(id: item.id, name: item.name)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Client streaming: the argument is an Enumerator that yields each request
|
|
202
|
+
as it arrives.
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
def import_items(requests)
|
|
206
|
+
created = requests.count { |r| Item.create!(name: r.name) }
|
|
207
|
+
Demo::V1::ImportItemsResponse.new(created: created)
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Bidirectional streaming combines both: take the request Enumerator and
|
|
212
|
+
return an Enumerable. Responses can be sent before the client finishes
|
|
213
|
+
sending.
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
def chat(messages)
|
|
217
|
+
Enumerator.new do |out|
|
|
218
|
+
messages.each { |m| out << Demo::V1::Reply.new(text: m.text.upcase) }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Errors
|
|
224
|
+
|
|
225
|
+
Raise `Tonic::Rails::RpcError` to end a call with a status:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
raise Tonic::Rails::RpcError.new("name is required", code: :invalid_argument)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Codes are the standard gRPC names (`:not_found`, `:permission_denied`,
|
|
232
|
+
`:unauthenticated`, `:unavailable`, and so on) or their numbers. The
|
|
233
|
+
message goes to the client, so keep internal details out of it.
|
|
234
|
+
|
|
235
|
+
Any other exception becomes `INTERNAL` with the message `internal error`.
|
|
236
|
+
The real exception is logged and passed to `Rails.error.report`, so your
|
|
237
|
+
error tracker sees it.
|
|
238
|
+
|
|
239
|
+
### Metadata, deadlines and shutdown
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
def get_item(request)
|
|
243
|
+
token = grpc_metadata["authorization"] # request headers, lower-case
|
|
244
|
+
deadline_ms # ms left before the client's deadline, or nil
|
|
245
|
+
deadline_exceeded? # true once it has passed
|
|
246
|
+
cancelled? # true while the server shuts down
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
When the deadline passes, the client gets `DEADLINE_EXCEEDED` right away;
|
|
251
|
+
check `deadline_exceeded?` in long loops to stop wasted work.
|
|
252
|
+
|
|
253
|
+
### Response metadata
|
|
254
|
+
|
|
255
|
+
Set response headers and trailers from a handler:
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
def get_item(request)
|
|
259
|
+
response_metadata["x-cache"] = "hit" # headers
|
|
260
|
+
trailing_metadata["x-cost-ms"] = "3" # trailers, sent with the status
|
|
261
|
+
response_metadata["x-trace-bin"] = trace_bytes # -bin names take binary values
|
|
262
|
+
Demo::V1::Item.new(id: request.id)
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
For streaming responses, set headers before the first message is sent.
|
|
267
|
+
Trailers are sent whether the call succeeds or fails.
|
|
268
|
+
|
|
269
|
+
### Rich errors
|
|
270
|
+
|
|
271
|
+
`RpcError` can carry trailing metadata and
|
|
272
|
+
[rich error details](https://grpc.io/docs/guides/error/#richer-error-model):
|
|
273
|
+
protobuf messages packed into a `google.rpc.Status` in the standard
|
|
274
|
+
`grpc-status-details-bin` trailer, which official gRPC clients decode for
|
|
275
|
+
you.
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
raise Tonic::Rails::RpcError.new(
|
|
279
|
+
"invalid item",
|
|
280
|
+
code: :invalid_argument,
|
|
281
|
+
metadata: { "x-request-id" => request_id },
|
|
282
|
+
details: [Google::Rpc::BadRequest.new(field_violations: violations)],
|
|
283
|
+
)
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Interceptors
|
|
287
|
+
|
|
288
|
+
Interceptors wrap every call, outermost first. Call `next_call` to
|
|
289
|
+
continue, or raise to reject:
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
Tonic::Rails.configure do |config|
|
|
293
|
+
config.interceptors << lambda do |path, metadata, next_call|
|
|
294
|
+
unless valid_token?(metadata["authorization"])
|
|
295
|
+
raise Tonic::Rails::RpcError.new("invalid token", code: :unauthenticated)
|
|
296
|
+
end
|
|
297
|
+
next_call.call
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Testing handlers
|
|
303
|
+
|
|
304
|
+
`Tonic::Rails::Testing` calls a handler in-process, with real protobuf
|
|
305
|
+
encoding, no network and no native extension:
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
require "tonic/rails/testing"
|
|
309
|
+
|
|
310
|
+
response = Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 1 })
|
|
311
|
+
expect(response.name).to eq("Widget")
|
|
312
|
+
|
|
313
|
+
expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
|
|
314
|
+
.to raise_error(Tonic::Rails::RpcError) { |e| expect(e.code_name).to eq(:not_found) }
|
|
315
|
+
|
|
316
|
+
# Streaming: pass an Array of requests, get an Array of responses
|
|
317
|
+
Tonic::Rails::Testing.call(ItemService, :ImportItems, [{ name: "a" }, { name: "b" }])
|
|
318
|
+
|
|
319
|
+
# Response metadata
|
|
320
|
+
result = Tonic::Rails::Testing.invoke(ItemService, :GetItem, { id: 1 })
|
|
321
|
+
result.headers["x-cache"] # => "hit"
|
|
322
|
+
result.trailers["x-cost-ms"] # => "3"
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Configuration
|
|
326
|
+
|
|
327
|
+
Set options in `config/application.rb` or an environment file:
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
config.tonic_rails.bind_address = "0.0.0.0:50051"
|
|
331
|
+
config.tonic_rails.dispatch_threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
or with a block (which runs after, and wins over, `config.tonic_rails`):
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
Tonic::Rails.configure do |config|
|
|
338
|
+
config.tls_cert_path = "/etc/tls/tls.crt"
|
|
339
|
+
config.tls_key_path = "/etc/tls/tls.key"
|
|
340
|
+
end
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
| Option | Default | Description |
|
|
344
|
+
|--------|---------|-------------|
|
|
345
|
+
| `bind_address` | `"0.0.0.0:50051"` | Address to listen on |
|
|
346
|
+
| `dispatch_threads` | `4` | Ruby threads running handlers; match your database pool |
|
|
347
|
+
| `dispatch_queue_size` | `128` | Calls that may wait for a thread; beyond this, `RESOURCE_EXHAUSTED` |
|
|
348
|
+
| `worker_threads` | CPU count | Tokio threads for the transport |
|
|
349
|
+
| `max_message_size` | 4 MiB | Request and response limit (also caps decompressed size) |
|
|
350
|
+
| `max_connections` | `64` | Concurrent connections; `0` = unlimited |
|
|
351
|
+
| `concurrency_limit_per_connection` | `32` | Concurrent streams per connection; `0` = unlimited |
|
|
352
|
+
| `tls_cert_path`, `tls_key_path` | `nil` | PEM certificate and key; set both to enable TLS |
|
|
353
|
+
| `tls_client_ca_path` | `nil` | PEM CA bundle; requires client certificates (mutual TLS) |
|
|
354
|
+
| `drain_timeout` | `10` | Seconds to let in-flight calls finish on shutdown |
|
|
355
|
+
| `keepalive_interval`, `keepalive_timeout` | `15`, `5` | HTTP/2 PING interval and timeout, seconds |
|
|
356
|
+
| `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
|
|
357
|
+
| `reflection` | `false` | Serve `grpc.reflection.v1`; exposes your schema |
|
|
358
|
+
| `interceptors` | `[]` | See [Interceptors](#interceptors) |
|
|
359
|
+
| `log_requests` | `false` | Log one line per call through `logger` (unexpected exceptions are always logged) |
|
|
360
|
+
| `auto_start` | `false` | Start in a thread of `rails server`; for development (see [auto_start](#auto_start)) |
|
|
361
|
+
|
|
362
|
+
### Sizing threads
|
|
363
|
+
|
|
364
|
+
Handlers run on `dispatch_threads` Ruby threads. Blocking I/O (database,
|
|
365
|
+
HTTP) releases the GVL, so more threads means more concurrent I/O. CPU
|
|
366
|
+
work does not parallelise within a process. Set threads to the
|
|
367
|
+
concurrency your I/O needs, size the database pool to match, and add
|
|
368
|
+
processes for CPU-heavy services: the listener uses `SO_REUSEPORT`, so
|
|
369
|
+
several processes can bind the same port.
|
|
370
|
+
|
|
371
|
+
### Running with Puma
|
|
372
|
+
|
|
373
|
+
Add the plugin to `config/puma.rb`:
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
plugin :tonic_rails
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Puma then runs the gRPC server as one child process, in single and cluster
|
|
380
|
+
mode alike, so gRPC gets its own process and GVL instead of competing with
|
|
381
|
+
web workers. It starts after Puma boots and drains gracefully when Puma
|
|
382
|
+
stops or restarts. If the gRPC process dies, Puma shuts down too, so your
|
|
383
|
+
supervisor restarts both. It works with `rails server`, plain `puma`, and
|
|
384
|
+
`preload_app!`. (Not on Windows, which has no `fork()`: run
|
|
385
|
+
`tonic_rails:serve` as its own process there.)
|
|
386
|
+
|
|
387
|
+
For larger deployments, a dedicated `bin/rails tonic_rails:serve` process
|
|
388
|
+
(scaled separately) is still the most flexible option.
|
|
389
|
+
|
|
390
|
+
### auto_start
|
|
391
|
+
|
|
392
|
+
`auto_start = true` starts the gRPC server in a background thread of
|
|
393
|
+
`rails server` (never in consoles, rake tasks or tests). It's handy in
|
|
394
|
+
development; in production use the Puma plugin or a dedicated process.
|
|
395
|
+
Don't combine it with the plugin.
|
|
396
|
+
|
|
397
|
+
## Operations
|
|
398
|
+
|
|
399
|
+
- **Health checks.** `grpc.health.v1.Health` reports `SERVING` for the
|
|
400
|
+
server (`""`) and every registered service, and flips to `NOT_SERVING`
|
|
401
|
+
as soon as shutdown begins. Point your load balancer or Kubernetes
|
|
402
|
+
gRPC probes at it.
|
|
403
|
+
- **Graceful shutdown.** On SIGTERM or SIGINT the server stops
|
|
404
|
+
accepting connections, tells clients to go away (HTTP/2 GOAWAY), lets
|
|
405
|
+
in-flight calls finish for up to `drain_timeout` seconds, then exits.
|
|
406
|
+
- **Load shedding.** When every dispatch thread is busy and the queue is
|
|
407
|
+
full, new calls fail fast with `RESOURCE_EXHAUSTED` instead of piling up.
|
|
408
|
+
- **Instrumentation.** Every RPC publishes a `process_rpc.tonic_rails`
|
|
409
|
+
event through `ActiveSupport::Notifications`, so APM agents,
|
|
410
|
+
OpenTelemetry and your own subscribers can trace calls. See
|
|
411
|
+
[Instrumentation](#instrumentation).
|
|
412
|
+
- **Logs.** Everything goes through your (Rails) logger. With
|
|
413
|
+
`log_requests = true`, each RPC logs one line, e.g.
|
|
414
|
+
`gRPC /demo.v1.ItemService/GetItem OK in 1.4ms`. Calls the transport
|
|
415
|
+
rejects before reaching Ruby (unknown methods, a full queue, expired
|
|
416
|
+
deadlines, oversized messages, refused connections) are always logged,
|
|
417
|
+
at info for client-caused problems, warn for overload and error for faults.
|
|
418
|
+
- **Stats.** `Tonic::Rails.server.stats` returns counters for totals,
|
|
419
|
+
successes, errors, cancellations, deadline and resource errors, active
|
|
420
|
+
connections and queued calls.
|
|
421
|
+
- **Reflection.** Turn on `reflection` in development to use
|
|
422
|
+
[grpcurl](https://github.com/fullstorydev/grpcurl) without proto files.
|
|
423
|
+
|
|
424
|
+
## Instrumentation
|
|
425
|
+
|
|
426
|
+
Every RPC is wrapped in an `ActiveSupport::Notifications` event, inside the
|
|
427
|
+
Rails executor, so subscribers see the same context as your handler:
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
ActiveSupport::Notifications.subscribe("process_rpc.tonic_rails") do |event|
|
|
431
|
+
event.payload[:path] # "/demo.v1.ItemService/GetItem"
|
|
432
|
+
event.payload[:service] # "demo.v1.ItemService"
|
|
433
|
+
event.payload[:rpc] # "GetItem"
|
|
434
|
+
event.payload[:metadata] # request metadata
|
|
435
|
+
event.payload[:status] # :ok, :not_found, :internal, ...
|
|
436
|
+
event.payload[:code] # numeric gRPC status
|
|
437
|
+
event.duration # milliseconds
|
|
438
|
+
end
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
When a call fails, `event.payload[:exception_object]` is the
|
|
442
|
+
`Tonic::Rails::RpcError` sent to the client. If a handler raised something
|
|
443
|
+
unexpected, that error's `cause` is the original exception.
|
|
444
|
+
|
|
445
|
+
## How it works
|
|
446
|
+
|
|
447
|
+
```
|
|
448
|
+
client ──HTTP/2──▶ tonic (Rust, no GVL)
|
|
449
|
+
├─ TLS, flow control, keepalive, limits
|
|
450
|
+
├─ grpc.health.v1, grpc.reflection.v1
|
|
451
|
+
└─ everything else ─▶ queue ─▶ Ruby dispatch thread
|
|
452
|
+
(takes the GVL)
|
|
453
|
+
decode → handler → encode
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
Rust never decodes your messages; it passes raw protobuf bytes to Ruby,
|
|
457
|
+
where the `google-protobuf` C extension decodes them once. Routes come
|
|
458
|
+
from the handlers you register, so nothing about your services is
|
|
459
|
+
compiled into the extension.
|
|
460
|
+
|
|
461
|
+
## Development
|
|
462
|
+
|
|
463
|
+
```sh
|
|
464
|
+
bundle install
|
|
465
|
+
bundle exec rake compile # build the extension (needs Rust)
|
|
466
|
+
bundle exec rake # Ruby specs + Rust unit tests
|
|
467
|
+
bundle exec rubocop
|
|
468
|
+
bundle exec rake cargo:lint
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
The integration specs start real servers and call them with the official
|
|
472
|
+
`grpc` gem; the reflection specs also need `grpcurl` on your PATH.
|
|
473
|
+
Benchmarks live in [bench/](bench/) (see [PERF.md](PERF.md)). See
|
|
474
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for more.
|
|
475
|
+
|
|
476
|
+
## License
|
|
477
|
+
|
|
478
|
+
MIT. See [LICENSE](LICENSE).
|
|
479
|
+
|
|
480
|
+
[tonic]: https://github.com/hyperium/tonic
|