tonic-rails 0.1.0-aarch64-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 +7 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE +21 -0
- data/README.md +391 -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 +215 -0
- data/lib/tonic/rails/configuration.rb +149 -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 +65 -0
- data/lib/tonic/rails/server.rb +224 -0
- data/lib/tonic/rails/tasks/tonic-rails.rake +36 -0
- data/lib/tonic/rails/testing.rb +85 -0
- data/lib/tonic/rails/version.rb +7 -0
- data/lib/tonic-rails.rb +91 -0
- metadata +107 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0b055719a1717245ea8a65525f12c18389f8209de1a17a1f474c35d76dff2a20
|
|
4
|
+
data.tar.gz: 1021a911c9ea4e1d2f3c86e8ce1d6b643d2d209807bda77782c87f827fcf1900
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 74deac3a8dba345a0a690571cdb56fc387b07de8643bef429f364b4ae9cffbb1e401ae0939a19cb33bc3558a34c8ba74434cb3068c4c71200c43a51519368473
|
|
7
|
+
data.tar.gz: 05abf283904c216b82bb94265cef50ec79886f02c0e585df343148fc7f7feaf8751309a671fe57a60255c198ef5d44fa1b0b2e0a26d5eae259d222f42162e144
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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.1.0]
|
|
11
|
+
|
|
12
|
+
First public release.
|
|
13
|
+
|
|
14
|
+
### Serving
|
|
15
|
+
|
|
16
|
+
- gRPC server for Rails built on tonic 0.14 (HTTP/2, TLS via rustls,
|
|
17
|
+
keepalive, flow control), with Ruby handlers running on a pool of
|
|
18
|
+
dispatch threads inside the Rails executor.
|
|
19
|
+
- Unary, server-streaming, client-streaming and bidirectional RPCs, with
|
|
20
|
+
backpressure in both directions and incremental streaming.
|
|
21
|
+
- Handlers declared with `rpc_service` (read from the protobuf descriptor
|
|
22
|
+
pool) or individual `rpc` calls; requests as Hashes or, with
|
|
23
|
+
`request_format :message`, protobuf messages; responses as either.
|
|
24
|
+
- Routes registered at runtime: no service definitions are compiled into
|
|
25
|
+
the native extension.
|
|
26
|
+
- Built-in `grpc.health.v1` (per service, flips to NOT_SERVING on
|
|
27
|
+
shutdown) and opt-in `grpc.reflection.v1` generated from loaded
|
|
28
|
+
descriptors.
|
|
29
|
+
- Interceptors, request metadata, client deadlines (`deadline_ms`,
|
|
30
|
+
`deadline_exceeded?`) and shutdown awareness (`cancelled?`).
|
|
31
|
+
- `Tonic::Rails::Testing.call` for in-process handler tests.
|
|
32
|
+
- Railtie: `config.tonic_rails.*` settings, Rails logger and error
|
|
33
|
+
reporter, code reloading support, rake tasks `tonic_rails:serve`,
|
|
34
|
+
`tonic_rails:routes` and `tonic_rails:compile_protos`.
|
|
35
|
+
|
|
36
|
+
### Production behaviour
|
|
37
|
+
|
|
38
|
+
- Graceful shutdown on SIGTERM/SIGINT: health flips to NOT_SERVING, new
|
|
39
|
+
connections are refused, in-flight calls drain for `drain_timeout`.
|
|
40
|
+
- Limits: `max_message_size` (also caps gzip-decompressed size),
|
|
41
|
+
`max_connections`, `concurrency_limit_per_connection`, request metadata
|
|
42
|
+
size, and `dispatch_queue_size` load shedding (`RESOURCE_EXHAUSTED`).
|
|
43
|
+
- TLS and mutual TLS (`tls_client_ca_path`). Invalid TLS configuration
|
|
44
|
+
stops startup; the server never falls back to plaintext.
|
|
45
|
+
- Unexpected exceptions (in handlers or interceptors) reach clients only
|
|
46
|
+
as `INTERNAL: internal error`; details are logged and reported.
|
|
47
|
+
- `grpc-message` is percent-encoded per the gRPC spec.
|
|
48
|
+
- Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
|
|
49
|
+
macOS (x86_64, arm64), Ruby 3.3 to 4.0.
|
|
50
|
+
|
|
51
|
+
[Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...HEAD
|
|
52
|
+
[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,391 @@
|
|
|
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.** Transport overhead is small: an empty RPC
|
|
34
|
+
runs at roughly 75–80% of a pure-Rust tonic server's throughput. I/O-bound
|
|
35
|
+
handlers scale with dispatch threads because Ruby releases the GVL
|
|
36
|
+
while waiting. See [PERF.md](PERF.md).
|
|
37
|
+
- **All four RPC kinds.** Unary, server streaming, client streaming and
|
|
38
|
+
bidirectional streaming, with backpressure in both directions.
|
|
39
|
+
- **Production defaults.** Graceful draining on SIGTERM, health checks
|
|
40
|
+
that flip to NOT_SERVING during shutdown, deadlines, message size and
|
|
41
|
+
connection limits, load shedding, gzip, TLS and mutual TLS.
|
|
42
|
+
- **Safe by default.** Unexpected exceptions reach clients as a generic
|
|
43
|
+
INTERNAL error (the details go to your logs and `Rails.error`).
|
|
44
|
+
Reflection is off unless you turn it on. TLS problems stop the server
|
|
45
|
+
instead of silently falling back to plaintext.
|
|
46
|
+
- **No Rust toolchain needed** on the platforms we ship precompiled gems
|
|
47
|
+
for (Linux and macOS, x86_64 and arm64).
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
# Gemfile
|
|
53
|
+
gem "tonic-rails"
|
|
54
|
+
gem "google-protobuf"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Requires Ruby 3.3+ and Rails 7.1+ (or just ActiveSupport, outside Rails).
|
|
58
|
+
On platforms without a precompiled gem, installation builds the
|
|
59
|
+
extension from source, which needs a [Rust toolchain](https://rustup.rs).
|
|
60
|
+
|
|
61
|
+
To generate Ruby classes from your `.proto` files you also need `protoc`
|
|
62
|
+
(the Protocol Buffers compiler) during development.
|
|
63
|
+
|
|
64
|
+
## Quick start
|
|
65
|
+
|
|
66
|
+
### 1. Define the service
|
|
67
|
+
|
|
68
|
+
```protobuf
|
|
69
|
+
// app/protos/items.proto
|
|
70
|
+
syntax = "proto3";
|
|
71
|
+
package demo.v1;
|
|
72
|
+
|
|
73
|
+
message Item { int64 id = 1; string name = 2; }
|
|
74
|
+
message GetItemRequest { int64 id = 1; }
|
|
75
|
+
|
|
76
|
+
service ItemService {
|
|
77
|
+
rpc GetItem(GetItemRequest) returns (Item);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Generate the Ruby classes:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
bin/rails tonic_rails:compile_protos # app/protos → lib/grpc
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Generated files `require` each other by name, so keep them out of the
|
|
88
|
+
autoload paths and put their directory on the load path:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# config/application.rb
|
|
92
|
+
$LOAD_PATH.unshift(File.expand_path("../lib/grpc", __dir__))
|
|
93
|
+
|
|
94
|
+
module MyApp
|
|
95
|
+
class Application < Rails::Application
|
|
96
|
+
config.autoload_lib(ignore: %w[grpc])
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 2. Write a handler
|
|
102
|
+
|
|
103
|
+
Handlers live anywhere Rails autoloads from; `app/grpc` is a good home.
|
|
104
|
+
Each RPC maps to a snake_case method.
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# app/grpc/item_service.rb
|
|
108
|
+
require "items_pb"
|
|
109
|
+
|
|
110
|
+
class ItemService
|
|
111
|
+
include Tonic::Rails::Base
|
|
112
|
+
|
|
113
|
+
rpc_service "demo.v1.ItemService" # every RPC, read from the descriptor
|
|
114
|
+
request_format :message # receive protobuf messages
|
|
115
|
+
|
|
116
|
+
def get_item(request)
|
|
117
|
+
item = Item.find(request.id)
|
|
118
|
+
Demo::V1::Item.new(id: item.id, name: item.name)
|
|
119
|
+
rescue ActiveRecord::RecordNotFound
|
|
120
|
+
raise Tonic::Rails::RpcError.new("item #{request.id} not found", code: :not_found)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 3. Register and run
|
|
126
|
+
|
|
127
|
+
Register handlers in `to_prepare` so they work with code reloading:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
# config/initializers/tonic_rails.rb
|
|
131
|
+
Rails.application.config.to_prepare do
|
|
132
|
+
Tonic::Rails.server.register(ItemService)
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
bin/rails tonic_rails:serve # serves on 0.0.0.0:50051
|
|
138
|
+
bin/rails tonic_rails:routes # lists registered methods
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Run it as its own process (a separate Procfile entry, container or
|
|
142
|
+
Kubernetes deployment) next to your web server.
|
|
143
|
+
|
|
144
|
+
The [example app](example/) is a complete, runnable version of all of the
|
|
145
|
+
above.
|
|
146
|
+
|
|
147
|
+
## Handlers
|
|
148
|
+
|
|
149
|
+
### Requests and responses
|
|
150
|
+
|
|
151
|
+
By default handlers receive a string-keyed Hash. With
|
|
152
|
+
`request_format :message` they receive the decoded protobuf message
|
|
153
|
+
instead, which is faster and type-safe. Either way, return a protobuf
|
|
154
|
+
message or a Hash:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
def get_item(request)
|
|
158
|
+
{ "id" => request.id, "name" => "Widget" } # a Hash works too
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A fresh handler instance serves each call, so instance variables never
|
|
163
|
+
leak between requests.
|
|
164
|
+
|
|
165
|
+
### Declaring RPCs
|
|
166
|
+
|
|
167
|
+
`rpc_service "package.Service"` reads every RPC (and its streaming kind)
|
|
168
|
+
from the generated descriptor. To declare RPCs one at a time instead:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
class ItemService
|
|
172
|
+
include Tonic::Rails::Base
|
|
173
|
+
service "demo.v1.ItemService"
|
|
174
|
+
|
|
175
|
+
rpc :GetItem, Demo::V1::GetItemRequest, Demo::V1::Item
|
|
176
|
+
rpc :ListItems, Demo::V1::ListItemsRequest, Demo::V1::Item, stream: :server
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
RPCs declared without a handler method answer `UNIMPLEMENTED`.
|
|
181
|
+
|
|
182
|
+
### Streaming
|
|
183
|
+
|
|
184
|
+
Server streaming: return any Enumerable. Messages go out as you produce
|
|
185
|
+
them, and if the client reads slowly your handler waits (without holding
|
|
186
|
+
the GVL) instead of buffering everything in memory.
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
def list_items(request)
|
|
190
|
+
Enumerator.new do |stream|
|
|
191
|
+
Item.where(tag: request.tag).find_each do |item|
|
|
192
|
+
break if cancelled? # the server is shutting down
|
|
193
|
+
stream << Demo::V1::Item.new(id: item.id, name: item.name)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Client streaming: the argument is an Enumerator that yields each request
|
|
200
|
+
as it arrives.
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
def import_items(requests)
|
|
204
|
+
created = requests.count { |r| Item.create!(name: r.name) }
|
|
205
|
+
Demo::V1::ImportItemsResponse.new(created: created)
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Bidirectional streaming combines both: take the request Enumerator and
|
|
210
|
+
return an Enumerable. Responses can be sent before the client finishes
|
|
211
|
+
sending.
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
def chat(messages)
|
|
215
|
+
Enumerator.new do |out|
|
|
216
|
+
messages.each { |m| out << Demo::V1::Reply.new(text: m.text.upcase) }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Errors
|
|
222
|
+
|
|
223
|
+
Raise `Tonic::Rails::RpcError` to end a call with a status:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
raise Tonic::Rails::RpcError.new("name is required", code: :invalid_argument)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Codes are the standard gRPC names (`:not_found`, `:permission_denied`,
|
|
230
|
+
`:unauthenticated`, `:unavailable`, and so on) or their numbers. The
|
|
231
|
+
message goes to the client, so keep internal details out of it.
|
|
232
|
+
|
|
233
|
+
Any other exception becomes `INTERNAL` with the message `internal error`.
|
|
234
|
+
The real exception is logged and passed to `Rails.error.report`, so your
|
|
235
|
+
error tracker sees it.
|
|
236
|
+
|
|
237
|
+
### Metadata, deadlines and shutdown
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
def get_item(request)
|
|
241
|
+
token = grpc_metadata["authorization"] # request headers, lower-case
|
|
242
|
+
deadline_ms # ms left before the client's deadline, or nil
|
|
243
|
+
deadline_exceeded? # true once it has passed
|
|
244
|
+
cancelled? # true while the server shuts down
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
When the deadline passes, the client gets `DEADLINE_EXCEEDED` right away;
|
|
249
|
+
check `deadline_exceeded?` in long loops to stop wasted work.
|
|
250
|
+
|
|
251
|
+
### Interceptors
|
|
252
|
+
|
|
253
|
+
Interceptors wrap every call, outermost first. Call `next_call` to
|
|
254
|
+
continue, or raise to reject:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
Tonic::Rails.configure do |config|
|
|
258
|
+
config.interceptors << lambda do |path, metadata, next_call|
|
|
259
|
+
unless valid_token?(metadata["authorization"])
|
|
260
|
+
raise Tonic::Rails::RpcError.new("invalid token", code: :unauthenticated)
|
|
261
|
+
end
|
|
262
|
+
next_call.call
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Testing handlers
|
|
268
|
+
|
|
269
|
+
`Tonic::Rails::Testing` calls a handler in-process, with real protobuf
|
|
270
|
+
encoding, no network and no native extension:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
require "tonic/rails/testing"
|
|
274
|
+
|
|
275
|
+
response = Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 1 })
|
|
276
|
+
expect(response.name).to eq("Widget")
|
|
277
|
+
|
|
278
|
+
expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
|
|
279
|
+
.to raise_error(Tonic::Rails::RpcError) { |e| expect(e.code_name).to eq(:not_found) }
|
|
280
|
+
|
|
281
|
+
# Streaming: pass an Array of requests, get an Array of responses
|
|
282
|
+
Tonic::Rails::Testing.call(ItemService, :ImportItems, [{ name: "a" }, { name: "b" }])
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## Configuration
|
|
286
|
+
|
|
287
|
+
Set options in `config/application.rb` or an environment file:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
config.tonic_rails.bind_address = "0.0.0.0:50051"
|
|
291
|
+
config.tonic_rails.dispatch_threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
or with a block (which runs after, and wins over, `config.tonic_rails`):
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
Tonic::Rails.configure do |config|
|
|
298
|
+
config.tls_cert_path = "/etc/tls/tls.crt"
|
|
299
|
+
config.tls_key_path = "/etc/tls/tls.key"
|
|
300
|
+
end
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
| Option | Default | Description |
|
|
304
|
+
|--------|---------|-------------|
|
|
305
|
+
| `bind_address` | `"0.0.0.0:50051"` | Address to listen on |
|
|
306
|
+
| `dispatch_threads` | `4` | Ruby threads running handlers; match your database pool |
|
|
307
|
+
| `dispatch_queue_size` | `128` | Calls that may wait for a thread; beyond this, `RESOURCE_EXHAUSTED` |
|
|
308
|
+
| `worker_threads` | CPU count | Tokio threads for the transport |
|
|
309
|
+
| `max_message_size` | 4 MiB | Request and response limit (also caps decompressed size) |
|
|
310
|
+
| `max_connections` | `64` | Concurrent connections; `0` = unlimited |
|
|
311
|
+
| `concurrency_limit_per_connection` | `32` | Concurrent streams per connection; `0` = unlimited |
|
|
312
|
+
| `tls_cert_path`, `tls_key_path` | `nil` | PEM certificate and key; set both to enable TLS |
|
|
313
|
+
| `tls_client_ca_path` | `nil` | PEM CA bundle; requires client certificates (mutual TLS) |
|
|
314
|
+
| `drain_timeout` | `10` | Seconds to let in-flight calls finish on shutdown |
|
|
315
|
+
| `keepalive_interval`, `keepalive_timeout` | `15`, `5` | HTTP/2 PING interval and timeout, seconds |
|
|
316
|
+
| `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
|
|
317
|
+
| `reflection` | `false` | Serve `grpc.reflection.v1`; exposes your schema |
|
|
318
|
+
| `interceptors` | `[]` | See [Interceptors](#interceptors) |
|
|
319
|
+
| `log_requests` | `false` | Log every call to stderr (errors are always logged) |
|
|
320
|
+
| `auto_start` | `false` | Start inside `rails server` (see below) |
|
|
321
|
+
|
|
322
|
+
### Sizing threads
|
|
323
|
+
|
|
324
|
+
Handlers run on `dispatch_threads` Ruby threads. Blocking I/O (database,
|
|
325
|
+
HTTP) releases the GVL, so more threads means more concurrent I/O. CPU
|
|
326
|
+
work does not parallelise within a process. Set threads to the
|
|
327
|
+
concurrency your I/O needs, size the database pool to match, and add
|
|
328
|
+
processes for CPU-heavy services: the listener uses `SO_REUSEPORT`, so
|
|
329
|
+
several processes can bind the same port.
|
|
330
|
+
|
|
331
|
+
### auto_start
|
|
332
|
+
|
|
333
|
+
`auto_start = true` starts the gRPC server in a background thread of
|
|
334
|
+
`rails server` (never in consoles, rake tasks or tests). It's handy in
|
|
335
|
+
development. In production prefer a dedicated `tonic_rails:serve`
|
|
336
|
+
process: forking servers such as Puma in cluster mode would need to
|
|
337
|
+
start one per worker.
|
|
338
|
+
|
|
339
|
+
## Operations
|
|
340
|
+
|
|
341
|
+
- **Health checks.** `grpc.health.v1.Health` reports `SERVING` for the
|
|
342
|
+
server (`""`) and every registered service, and flips to `NOT_SERVING`
|
|
343
|
+
as soon as shutdown begins. Point your load balancer or Kubernetes
|
|
344
|
+
gRPC probes at it.
|
|
345
|
+
- **Graceful shutdown.** On SIGTERM or SIGINT the server stops
|
|
346
|
+
accepting connections, tells clients to go away (HTTP/2 GOAWAY), lets
|
|
347
|
+
in-flight calls finish for up to `drain_timeout` seconds, then exits.
|
|
348
|
+
- **Load shedding.** When every dispatch thread is busy and the queue is
|
|
349
|
+
full, new calls fail fast with `RESOURCE_EXHAUSTED` instead of piling up.
|
|
350
|
+
- **Stats.** `Tonic::Rails.server.stats` returns counters for totals,
|
|
351
|
+
successes, errors, cancellations, deadline and resource errors, active
|
|
352
|
+
connections and queued calls.
|
|
353
|
+
- **Reflection.** Turn on `reflection` in development to use
|
|
354
|
+
[grpcurl](https://github.com/fullstorydev/grpcurl) without proto files.
|
|
355
|
+
|
|
356
|
+
## How it works
|
|
357
|
+
|
|
358
|
+
```
|
|
359
|
+
client ──HTTP/2──▶ tonic (Rust, no GVL)
|
|
360
|
+
├─ TLS, flow control, keepalive, limits
|
|
361
|
+
├─ grpc.health.v1, grpc.reflection.v1
|
|
362
|
+
└─ everything else ─▶ queue ─▶ Ruby dispatch thread
|
|
363
|
+
(takes the GVL)
|
|
364
|
+
decode → handler → encode
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
Rust never decodes your messages; it passes raw protobuf bytes to Ruby,
|
|
368
|
+
where the `google-protobuf` C extension decodes them once. Routes come
|
|
369
|
+
from the handlers you register, so nothing about your services is
|
|
370
|
+
compiled into the extension.
|
|
371
|
+
|
|
372
|
+
## Development
|
|
373
|
+
|
|
374
|
+
```sh
|
|
375
|
+
bundle install
|
|
376
|
+
bundle exec rake compile # build the extension (needs Rust)
|
|
377
|
+
bundle exec rake # Ruby specs + Rust unit tests
|
|
378
|
+
bundle exec rubocop
|
|
379
|
+
bundle exec rake cargo:lint
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
The integration specs start real servers and call them with the official
|
|
383
|
+
`grpc` gem; the reflection specs also need `grpcurl` on your PATH.
|
|
384
|
+
Benchmarks live in [bench/](bench/) (see [PERF.md](PERF.md)). See
|
|
385
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) for more.
|
|
386
|
+
|
|
387
|
+
## License
|
|
388
|
+
|
|
389
|
+
MIT. See [LICENSE](LICENSE).
|
|
390
|
+
|
|
391
|
+
[tonic]: https://github.com/hyperium/tonic
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/class/attribute"
|
|
4
|
+
require "active_support/core_ext/hash/keys"
|
|
5
|
+
require "active_support/core_ext/string/inflections"
|
|
6
|
+
|
|
7
|
+
module Tonic
|
|
8
|
+
module Rails
|
|
9
|
+
# Include in a class to define a gRPC service handler.
|
|
10
|
+
#
|
|
11
|
+
# class ProductService
|
|
12
|
+
# include Tonic::Rails::Base
|
|
13
|
+
# rpc_service "shop.ProductService" # every RPC from the descriptor
|
|
14
|
+
#
|
|
15
|
+
# def get_product(request)
|
|
16
|
+
# Shop::Product.new(id: request.id, title: "Widget")
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Handler methods are the snake_case RPC names. A new instance is
|
|
21
|
+
# created for every call, so instance variables never leak between
|
|
22
|
+
# requests.
|
|
23
|
+
#
|
|
24
|
+
# Requests are decoded protobuf messages when `request_format :message`
|
|
25
|
+
# is set (fastest), or string-keyed Hashes by default. Handlers may
|
|
26
|
+
# return either a protobuf message or a Hash; streaming handlers
|
|
27
|
+
# return any Enumerable of them.
|
|
28
|
+
module Base
|
|
29
|
+
def self.included(base)
|
|
30
|
+
base.extend(ClassMethods)
|
|
31
|
+
base.class_attribute :grpc_service_name, instance_writer: false
|
|
32
|
+
base.class_attribute :grpc_rpcs, instance_writer: false, default: {}
|
|
33
|
+
base.class_attribute :grpc_request_format, instance_writer: false, default: :hash
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
module ClassMethods
|
|
37
|
+
# @param name [String] fully-qualified service name, e.g. "shop.ProductService"
|
|
38
|
+
def service(name)
|
|
39
|
+
self.grpc_service_name = name.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# How requests are passed to handlers.
|
|
43
|
+
# @param format [Symbol] :hash (string-keyed Hash, the default) or
|
|
44
|
+
# :message (the decoded protobuf message — avoids a conversion)
|
|
45
|
+
def request_format(format)
|
|
46
|
+
unless %i[hash message].include?(format)
|
|
47
|
+
raise ArgumentError, "request_format must be :hash or :message, got #{format.inspect}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
self.grpc_request_format = format
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Declare one RPC.
|
|
54
|
+
# @param method_name [Symbol, String] RPC name as in the .proto, e.g. :GetProduct
|
|
55
|
+
# @param request_class [Class] protobuf request message class
|
|
56
|
+
# @param response_class [Class] protobuf response message class
|
|
57
|
+
# @param stream [Symbol, nil] :server, :client, :bidi, or nil for unary
|
|
58
|
+
def rpc(method_name, request_class, response_class, stream: nil)
|
|
59
|
+
unless [nil, :server, :client, :bidi].include?(stream)
|
|
60
|
+
raise ArgumentError, "stream must be :server, :client, :bidi, or nil, got #{stream.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
name = method_name.to_s
|
|
64
|
+
self.grpc_rpcs = grpc_rpcs.merge(
|
|
65
|
+
name => {
|
|
66
|
+
request: request_class,
|
|
67
|
+
response: response_class,
|
|
68
|
+
request_stream: %i[client bidi].include?(stream),
|
|
69
|
+
response_stream: %i[server bidi].include?(stream),
|
|
70
|
+
handler: name.underscore.to_sym,
|
|
71
|
+
}.freeze,
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Declare the service and every RPC in it from the protobuf
|
|
76
|
+
# descriptor pool. Load the generated `_pb.rb` file first.
|
|
77
|
+
#
|
|
78
|
+
# @param full_name [String] fully-qualified service name
|
|
79
|
+
def rpc_service(full_name)
|
|
80
|
+
descriptor = Google::Protobuf::DescriptorPool.generated_pool.lookup(full_name.to_s)
|
|
81
|
+
unless descriptor.is_a?(Google::Protobuf::ServiceDescriptor)
|
|
82
|
+
raise NotConfiguredError,
|
|
83
|
+
"rpc_service: #{full_name} is not a known protobuf service. " \
|
|
84
|
+
"Require its generated _pb.rb file (protoc --ruby_out) before declaring the handler."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
service(full_name)
|
|
88
|
+
descriptor.each do |method|
|
|
89
|
+
stream = if method.client_streaming && method.server_streaming
|
|
90
|
+
:bidi
|
|
91
|
+
elsif method.client_streaming
|
|
92
|
+
:client
|
|
93
|
+
elsif method.server_streaming
|
|
94
|
+
:server
|
|
95
|
+
end
|
|
96
|
+
rpc(method.name, method.input_type.msgclass, method.output_type.msgclass, stream: stream)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @api private Called by Server#register.
|
|
101
|
+
def register_on(router)
|
|
102
|
+
raise NotConfiguredError, "#{self} has no service name; call `service` or `rpc_service`" unless grpc_service_name
|
|
103
|
+
raise NotConfiguredError, "#{self} declares no RPCs" if grpc_rpcs.empty?
|
|
104
|
+
|
|
105
|
+
router.add(self)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# gRPC request metadata (lower-case header names). Binary (`-bin`)
|
|
110
|
+
# values are base64-encoded, as sent on the wire.
|
|
111
|
+
# @return [Hash{String=>String}]
|
|
112
|
+
attr_reader :grpc_metadata
|
|
113
|
+
|
|
114
|
+
# Milliseconds left before the client's deadline, or nil if the
|
|
115
|
+
# client did not set one.
|
|
116
|
+
# @return [Integer, nil]
|
|
117
|
+
def deadline_ms
|
|
118
|
+
return unless @grpc_deadline_at
|
|
119
|
+
|
|
120
|
+
remaining = ((@grpc_deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)) * 1000).to_i
|
|
121
|
+
remaining.negative? ? 0 : remaining
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# True when the deadline has passed.
|
|
125
|
+
def deadline_exceeded?
|
|
126
|
+
!deadline_ms.nil? && deadline_ms.zero?
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# True when the server is shutting down.
|
|
130
|
+
def cancelled?
|
|
131
|
+
Tonic::Rails.cancelled?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @api private
|
|
135
|
+
# Run one RPC. `reader` yields raw request messages (`#next`, then
|
|
136
|
+
# `#error` once exhausted); `sender` accepts raw response messages
|
|
137
|
+
# (`#push`) for server-streaming RPCs.
|
|
138
|
+
# @return [String, nil] the encoded unary response, or nil when
|
|
139
|
+
# messages were streamed through `sender`
|
|
140
|
+
def call_rpc(rpc_name, metadata:, reader:, sender: nil, timeout_ms: nil)
|
|
141
|
+
spec = self.class.grpc_rpcs[rpc_name.to_s]
|
|
142
|
+
raise RpcError.new("method not implemented", code: :unimplemented) unless spec
|
|
143
|
+
raise RpcError.new("method #{rpc_name} not implemented", code: :unimplemented) unless respond_to?(spec[:handler])
|
|
144
|
+
|
|
145
|
+
@grpc_metadata = metadata
|
|
146
|
+
@grpc_deadline_at = timeout_ms && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + (timeout_ms / 1000.0))
|
|
147
|
+
|
|
148
|
+
request = if spec[:request_stream]
|
|
149
|
+
Enumerator.new do |y|
|
|
150
|
+
while (message = read_request(reader, spec))
|
|
151
|
+
y << message
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
else
|
|
155
|
+
read_request(reader, spec) || raise(RpcError.new("missing request message", code: :invalid_argument))
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
result = public_send(spec[:handler], request)
|
|
159
|
+
|
|
160
|
+
if spec[:response_stream]
|
|
161
|
+
unless result.respond_to?(:each)
|
|
162
|
+
raise TypeError, "streaming handler #{self.class}##{spec[:handler]} must return an Enumerable, got #{result.class}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
result.each { |item| push_response(sender, encode_response(item, spec)) }
|
|
166
|
+
nil
|
|
167
|
+
else
|
|
168
|
+
encode_response(result, spec)
|
|
169
|
+
end
|
|
170
|
+
rescue RpcError
|
|
171
|
+
raise
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
Tonic::Rails.log_exception(e, "#{self.class}##{rpc_name}")
|
|
174
|
+
raise RpcError.new("internal error", code: :internal)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def read_request(reader, spec)
|
|
180
|
+
bytes = reader.next
|
|
181
|
+
if bytes.nil?
|
|
182
|
+
code, message = reader.error
|
|
183
|
+
raise RpcError.new(message, code: code) if code
|
|
184
|
+
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
message = begin
|
|
189
|
+
spec[:request].decode(bytes)
|
|
190
|
+
rescue Google::Protobuf::ParseError, ArgumentError
|
|
191
|
+
raise RpcError.new("invalid protobuf request", code: :invalid_argument)
|
|
192
|
+
end
|
|
193
|
+
self.class.grpc_request_format == :message ? message : message.to_h.deep_stringify_keys
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def encode_response(item, spec)
|
|
197
|
+
klass = spec[:response]
|
|
198
|
+
message = case item
|
|
199
|
+
when klass then item
|
|
200
|
+
when Hash then klass.new(item)
|
|
201
|
+
else
|
|
202
|
+
raise TypeError, "#{self.class}##{spec[:handler]} must return a #{klass} or Hash, got #{item.class}"
|
|
203
|
+
end
|
|
204
|
+
klass.encode(message)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def push_response(sender, bytes)
|
|
208
|
+
sender.push(bytes)
|
|
209
|
+
rescue IOError
|
|
210
|
+
# The client went away; nothing left to deliver to.
|
|
211
|
+
raise RpcError.new("client cancelled the stream", code: :cancelled)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|