tonic-rails 0.1.0-x86_64-linux-gnu → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b80385e6e34361d398f683ec43379d519558c45f7762280c278bb52c545316f6
4
- data.tar.gz: 1f25a57bf202c59274c178981967aeee274d33ab85aaf6517ca3261ae115a8ae
3
+ metadata.gz: 73ed3d0d421cd1cb77193b7d5025925cbe8737022553d28f7c95bd0d7ca2242a
4
+ data.tar.gz: e75d6f30c34e225b6668cdbe7f8072c22b5a5b1e53c782962f4327873cfcf7b2
5
5
  SHA512:
6
- metadata.gz: 9a6f385bfd4b2fe956e225888cca22663302b31f6a8c3ca97c3edb3a6f6b5ec13ba1224cc37788724ababd4c518e9eaf06f6234c4aa9d380ec6aeffc9b959af1
7
- data.tar.gz: a9e4c94967a476ceb84d11135fe3c6a0755219c3808736692d656df6683587d8e362dd00bb3b8f6b8f1ddddf8a0625fdc0bf6c0918c1529b5e97d9a24152baad
6
+ metadata.gz: d66dbcc4d4bd4721d007fd0ace7004fc2af5ae1c9f209c082e2004d38892fa96373c22375db9da6dbeae358d93b9a0f69c30ba7f3309f0a8bed3615dcb9c11a3
7
+ data.tar.gz: 209db51bbf3305ed9aeba24a9c407653f08397b875490d295e3ebccaf3667182869ee5a950fe78a747c3b7d3799f5ae879aa9453dc594fbbd8ddedca66bb3fa9
data/CHANGELOG.md CHANGED
@@ -7,7 +7,43 @@ minor versions may contain breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.1.0]
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
11
47
 
12
48
  First public release.
13
49
 
@@ -48,5 +84,6 @@ First public release.
48
84
  - Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
49
85
  macOS (x86_64, arm64), Ruby 3.3 to 4.0.
50
86
 
51
- [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...HEAD
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
52
89
  [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.** 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).
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 (Linux and macOS, x86_64 and arm64).
48
+ for: Linux and macOS (x86_64 and arm64) and Windows (x64).
48
49
 
49
50
  ## Installation
50
51
 
@@ -139,7 +140,8 @@ bin/rails tonic_rails:routes # lists registered methods
139
140
  ```
140
141
 
141
142
  Run it as its own process (a separate Procfile entry, container or
142
- Kubernetes deployment) next to your web server.
143
+ Kubernetes deployment) next to your web server, or let Puma run it for
144
+ you (see [Running with Puma](#running-with-puma)).
143
145
 
144
146
  The [example app](example/) is a complete, runnable version of all of the
145
147
  above.
@@ -248,6 +250,39 @@ end
248
250
  When the deadline passes, the client gets `DEADLINE_EXCEEDED` right away;
249
251
  check `deadline_exceeded?` in long loops to stop wasted work.
250
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
+
251
286
  ### Interceptors
252
287
 
253
288
  Interceptors wrap every call, outermost first. Call `next_call` to
@@ -280,6 +315,11 @@ expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
280
315
 
281
316
  # Streaming: pass an Array of requests, get an Array of responses
282
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"
283
323
  ```
284
324
 
285
325
  ## Configuration
@@ -316,8 +356,8 @@ end
316
356
  | `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
317
357
  | `reflection` | `false` | Serve `grpc.reflection.v1`; exposes your schema |
318
358
  | `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) |
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)) |
321
361
 
322
362
  ### Sizing threads
323
363
 
@@ -328,13 +368,31 @@ concurrency your I/O needs, size the database pool to match, and add
328
368
  processes for CPU-heavy services: the listener uses `SO_REUSEPORT`, so
329
369
  several processes can bind the same port.
330
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
+
331
390
  ### auto_start
332
391
 
333
392
  `auto_start = true` starts the gRPC server in a background thread of
334
393
  `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.
394
+ development; in production use the Puma plugin or a dedicated process.
395
+ Don't combine it with the plugin.
338
396
 
339
397
  ## Operations
340
398
 
@@ -347,12 +405,43 @@ start one per worker.
347
405
  in-flight calls finish for up to `drain_timeout` seconds, then exits.
348
406
  - **Load shedding.** When every dispatch thread is busy and the queue is
349
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.
350
418
  - **Stats.** `Tonic::Rails.server.stats` returns counters for totals,
351
419
  successes, errors, cancellations, deadline and resource errors, active
352
420
  connections and queued calls.
353
421
  - **Reflection.** Turn on `reflection` in development to use
354
422
  [grpcurl](https://github.com/fullstorydev/grpcurl) without proto files.
355
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
+
356
445
  ## How it works
357
446
 
358
447
  ```
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "puma/plugin"
4
+
5
+ # Runs the tonic-rails gRPC server alongside Puma:
6
+ #
7
+ # # config/puma.rb
8
+ # plugin :tonic_rails
9
+ #
10
+ # The gRPC server runs as one child process of the Puma master, in single
11
+ # and cluster mode alike, so it has its own GVL and never competes with web
12
+ # workers. It starts once Puma has booted, drains gracefully when Puma
13
+ # stops or restarts, stops itself if Puma goes away, and if it dies
14
+ # unexpectedly Puma shuts down too, so a supervisor can restart both.
15
+ Puma::Plugin.create do
16
+ def start(launcher)
17
+ @launcher = launcher
18
+ unless Process.respond_to?(:fork)
19
+ log "tonic-rails: the Puma plugin needs fork(), which this platform lacks; " \
20
+ "run `bin/rails tonic_rails:serve` as a separate process instead"
21
+ return
22
+ end
23
+
24
+ @puma_pid = Process.pid
25
+ @grpc_pid = nil
26
+
27
+ events = launcher.events
28
+ if events.respond_to?(:after_booted) # Puma 7+
29
+ events.after_booted { start_grpc }
30
+ events.after_stopped { stop_grpc }
31
+ events.before_restart { stop_grpc }
32
+ else
33
+ events.on_booted { start_grpc }
34
+ events.on_stopped { stop_grpc }
35
+ events.on_restart { stop_grpc }
36
+ end
37
+
38
+ in_background { monitor_grpc }
39
+ end
40
+
41
+ private
42
+
43
+ def start_grpc
44
+ @grpc_pid = fork { run_grpc_server }
45
+ log "tonic-rails: gRPC server started (pid #{@grpc_pid})"
46
+ end
47
+
48
+ def run_grpc_server
49
+ # The web listeners belong to Puma; don't hold them open in this process.
50
+ @launcher.binder.ios.each { |io| io.close rescue nil } # rubocop:disable Style/RescueModifier
51
+ load_rails_app
52
+ Thread.new { stop_when_puma_exits }
53
+ Tonic::Rails.server.start
54
+ end
55
+
56
+ # `rails server` and preload_app! boot the app before forking; plain
57
+ # `puma` in cluster mode does not, so boot it here.
58
+ def load_rails_app
59
+ return if defined?(Rails.application) && Rails.application&.initialized?
60
+
61
+ require File.expand_path("config/environment", Dir.pwd)
62
+ end
63
+
64
+ # Puma fires its stop hooks from its TERM trap handler, so this must
65
+ # not block indefinitely: allow the server's drain timeout plus a margin,
66
+ # then kill it.
67
+ def stop_grpc
68
+ pid = @grpc_pid
69
+ return unless pid
70
+
71
+ @grpc_pid = nil
72
+ log "tonic-rails: stopping gRPC server (pid #{pid})"
73
+ Process.kill(:TERM, pid)
74
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + stop_timeout
75
+ until Process.waitpid(pid, Process::WNOHANG)
76
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
77
+ log "tonic-rails: gRPC server (pid #{pid}) did not stop in #{stop_timeout}s; killing it"
78
+ Process.kill(:KILL, pid)
79
+ Process.wait(pid)
80
+ break
81
+ end
82
+ sleep 0.05
83
+ end
84
+ rescue Errno::ESRCH, Errno::ECHILD
85
+ nil
86
+ end
87
+
88
+ def stop_timeout
89
+ drain = defined?(Tonic::Rails) ? Tonic::Rails.configuration.drain_timeout.to_f : 10.0
90
+ drain + 5
91
+ end
92
+
93
+ def monitor_grpc
94
+ loop do
95
+ sleep 1
96
+ pid = @grpc_pid
97
+ next unless pid && Process.waitpid(pid, Process::WNOHANG)
98
+
99
+ @grpc_pid = nil
100
+ log "tonic-rails: gRPC server (pid #{pid}) exited unexpectedly; stopping Puma"
101
+ Process.kill(:TERM, @puma_pid)
102
+ break
103
+ rescue Errno::ECHILD
104
+ nil
105
+ end
106
+ end
107
+
108
+ def stop_when_puma_exits
109
+ sleep 1 while Process.ppid == @puma_pid
110
+ Tonic::Rails.server.stop
111
+ end
112
+
113
+ def log(message)
114
+ @launcher.log_writer.log(message)
115
+ end
116
+ end
@@ -111,6 +111,21 @@ module Tonic
111
111
  # @return [Hash{String=>String}]
112
112
  attr_reader :grpc_metadata
113
113
 
114
+ # Response headers (initial metadata) for this call. For streaming
115
+ # responses, set them before the first message; for unary RPCs,
116
+ # anywhere in the handler. `-bin` names take binary values.
117
+ # @return [Hash]
118
+ def response_metadata
119
+ @response_metadata ||= {}
120
+ end
121
+
122
+ # Trailing metadata, sent with the final status, whether the call
123
+ # succeeds or fails.
124
+ # @return [Hash]
125
+ def trailing_metadata
126
+ @trailing_metadata ||= {}
127
+ end
128
+
114
129
  # Milliseconds left before the client's deadline, or nil if the
115
130
  # client did not set one.
116
131
  # @return [Integer, nil]
@@ -143,39 +158,52 @@ module Tonic
143
158
  raise RpcError.new("method #{rpc_name} not implemented", code: :unimplemented) unless respond_to?(spec[:handler])
144
159
 
145
160
  @grpc_metadata = metadata
161
+ @grpc_sender = sender
162
+ @grpc_headers_sent = false
146
163
  @grpc_deadline_at = timeout_ms && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + (timeout_ms / 1000.0))
147
164
 
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
165
+ result = public_send(spec[:handler], build_request(reader, spec))
166
+ respond(result, spec, sender)
170
167
  rescue RpcError
171
168
  raise
172
169
  rescue StandardError => e
173
170
  Tonic::Rails.log_exception(e, "#{self.class}##{rpc_name}")
174
171
  raise RpcError.new("internal error", code: :internal)
172
+ ensure
173
+ attach_trailing_metadata
175
174
  end
176
175
 
177
176
  private
178
177
 
178
+ def build_request(reader, spec)
179
+ unless spec[:request_stream]
180
+ return read_request(reader, spec) || raise(RpcError.new("missing request message", code: :invalid_argument))
181
+ end
182
+
183
+ Enumerator.new do |y|
184
+ while (message = read_request(reader, spec))
185
+ y << message
186
+ end
187
+ end
188
+ end
189
+
190
+ # Unary: the encoded response. Streaming: nil, after pushing each
191
+ # message through the sender.
192
+ def respond(result, spec, sender)
193
+ unless spec[:response_stream]
194
+ encoded = encode_response(result, spec)
195
+ send_response_headers
196
+ return encoded
197
+ end
198
+
199
+ unless result.respond_to?(:each)
200
+ raise TypeError, "streaming handler #{self.class}##{spec[:handler]} must return an Enumerable, got #{result.class}"
201
+ end
202
+
203
+ result.each { |item| push_response(sender, encode_response(item, spec)) }
204
+ nil
205
+ end
206
+
179
207
  def read_request(reader, spec)
180
208
  bytes = reader.next
181
209
  if bytes.nil?
@@ -205,11 +233,30 @@ module Tonic
205
233
  end
206
234
 
207
235
  def push_response(sender, bytes)
236
+ send_response_headers
208
237
  sender.push(bytes)
209
238
  rescue IOError
210
239
  # The client went away; nothing left to deliver to.
211
240
  raise RpcError.new("client cancelled the stream", code: :cancelled)
212
241
  end
242
+
243
+ def send_response_headers
244
+ return if @grpc_headers_sent
245
+
246
+ @grpc_headers_sent = true
247
+ return if @response_metadata.nil? || @response_metadata.empty? || @grpc_sender.nil?
248
+
249
+ @grpc_sender.send_headers(Metadata.encode(@response_metadata))
250
+ end
251
+
252
+ # Runs in `ensure`, so it must never raise over the call's outcome.
253
+ def attach_trailing_metadata
254
+ return if @trailing_metadata.nil? || @trailing_metadata.empty? || @grpc_sender.nil?
255
+
256
+ @grpc_sender.add_trailers(Metadata.encode(@trailing_metadata))
257
+ rescue StandardError => e
258
+ Tonic::Rails.log_exception(e, "#{self.class} trailing metadata")
259
+ end
213
260
  end
214
261
  end
215
262
  end
@@ -47,7 +47,8 @@ module Tonic
47
47
  # Concurrent HTTP/2 streams per connection. 0 = unlimited. Default 32.
48
48
  attr_accessor :concurrency_limit_per_connection
49
49
 
50
- # Log every request, not just errors, to stderr. Default false.
50
+ # Log one line per RPC (path, status, duration) through `logger`.
51
+ # Unexpected exceptions are always logged. Default false.
51
52
  attr_accessor :log_requests
52
53
 
53
54
  # Callables wrapping every RPC, outermost first:
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/notifications"
5
+
6
+ module Tonic
7
+ module Rails
8
+ # Every RPC is wrapped in an ActiveSupport::Notifications event, so APM
9
+ # agents, OpenTelemetry and your own subscribers can observe it:
10
+ #
11
+ # ActiveSupport::Notifications.subscribe("process_rpc.tonic_rails") do |event|
12
+ # event.payload[:path] # "/demo.v1.ItemService/GetItem"
13
+ # event.payload[:service] # "demo.v1.ItemService"
14
+ # event.payload[:rpc] # "GetItem"
15
+ # event.payload[:metadata] # request metadata Hash
16
+ # event.payload[:code] # gRPC status code, 0 = OK
17
+ # event.payload[:status] # :ok, :not_found, :internal, ...
18
+ # event.duration # milliseconds
19
+ # end
20
+ #
21
+ # When a call fails, :exception_object holds the RpcError. If a handler
22
+ # raised something unexpected, that RpcError's #cause is the original
23
+ # exception.
24
+ module Instrumentation
25
+ EVENT = "process_rpc.tonic_rails"
26
+
27
+ module_function
28
+
29
+ # @api private
30
+ def instrument(path, metadata)
31
+ # Without subscribers, skip building the payload: this runs on
32
+ # every RPC.
33
+ return yield unless ActiveSupport::Notifications.notifier.listening?(EVENT)
34
+
35
+ service, rpc = path.delete_prefix("/").split("/", 2)
36
+ payload = { path: path, service: service, rpc: rpc, metadata: metadata, code: nil, status: nil }
37
+ ActiveSupport::Notifications.instrument(EVENT, payload) do
38
+ result = yield
39
+ payload[:code] = 0
40
+ payload[:status] = :ok
41
+ result
42
+ rescue RpcError => e
43
+ payload[:code] = e.code
44
+ payload[:status] = e.code_name
45
+ raise
46
+ rescue StandardError
47
+ payload[:code] = RpcError::CODES[:internal]
48
+ payload[:status] = :internal
49
+ raise
50
+ end
51
+ end
52
+
53
+ # Logs one line per RPC through the configured logger, e.g.
54
+ # gRPC /demo.v1.ItemService/GetItem OK in 1.4ms
55
+ # gRPC /demo.v1.ItemService/GetItem NOT_FOUND in 0.9ms: item 7 not found
56
+ # Enabled by `config.log_requests = true`.
57
+ class RequestLogger
58
+ def initialize(logger)
59
+ @logger = logger
60
+ end
61
+
62
+ def call(event)
63
+ payload = event.payload
64
+ status = (payload[:status] || :unknown).to_s.upcase
65
+ line = format("gRPC %<path>s %<status>s in %<ms>.1fms", path: payload[:path], status: status, ms: event.duration)
66
+ error = payload[:exception_object]
67
+ line = "#{line}: #{error.message}" if error && payload[:code] != 0
68
+ @logger.info(line)
69
+ end
70
+
71
+ # @return [Object] the subscription, for #unsubscribe
72
+ def subscribe
73
+ ActiveSupport::Notifications.subscribe(EVENT, self)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # Converts handler-supplied gRPC metadata into header pairs.
6
+ #
7
+ # Names are lower-cased and must be valid gRPC metadata names; names
8
+ # ending in "-bin" carry binary values, which are base64-encoded as the
9
+ # gRPC spec requires. Other values must be printable ASCII.
10
+ module Metadata
11
+ NAME = /\A[0-9a-z_.-]+\z/
12
+ PRINTABLE = /\A[\x20-\x7e]*\z/
13
+ RESERVED = %w[content-type te user-agent content-length connection transfer-encoding].freeze
14
+
15
+ module_function
16
+
17
+ # @param metadata [Hash{String,Symbol=>String,Array<String>}]
18
+ # @return [Hash{String=>String}]
19
+ # @raise [ArgumentError] for invalid or reserved names and values
20
+ def encode(metadata)
21
+ metadata.each_with_object({}) do |(name, value), out|
22
+ name = name.to_s.downcase
23
+ validate_name!(name)
24
+ out[name] = encode_value(name, value)
25
+ end
26
+ end
27
+
28
+ def validate_name!(name)
29
+ raise ArgumentError, "invalid gRPC metadata name #{name.inspect}" unless NAME.match?(name)
30
+ return unless RESERVED.include?(name) || (name.start_with?("grpc-") && name != "grpc-status-details-bin")
31
+
32
+ raise ArgumentError, "gRPC metadata name #{name.inspect} is reserved"
33
+ end
34
+
35
+ def encode_value(name, value)
36
+ values = Array(value).map(&:to_s)
37
+ if name.end_with?("-bin")
38
+ values.map { |v| [v].pack("m0") }.join(",")
39
+ else
40
+ values.each do |v|
41
+ next if PRINTABLE.match?(v)
42
+
43
+ raise ArgumentError, "gRPC metadata #{name} must be printable ASCII; use a -bin name for binary"
44
+ end
45
+ values.join(",")
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -28,8 +28,8 @@ module Tonic
28
28
  config.after_initialize do
29
29
  configuration = Tonic::Rails.configuration
30
30
  # Only inside an actual web server process — not consoles, rake
31
- # tasks, or test runs. Forking servers (Puma cluster mode) must
32
- # instead start the server per worker, e.g. in `on_worker_boot`.
31
+ # tasks, or test runs. With Puma, prefer `plugin :tonic_rails`
32
+ # (lib/puma/plugin/tonic_rails.rb), which also covers cluster mode.
33
33
  if configuration.auto_start && defined?(::Rails::Server)
34
34
  server = Tonic::Rails.server
35
35
  thread = Thread.new do
@@ -2,13 +2,23 @@
2
2
 
3
3
  module Tonic
4
4
  module Rails
5
- # Raise from a handler to end the RPC with a gRPC status.
5
+ # Raise from a handler (or interceptor) to end the RPC with a gRPC status.
6
6
  #
7
- # raise Tonic::Rails::RpcError.new("product not found", code: :not_found)
7
+ # raise Tonic::Rails::RpcError.new("item not found", code: :not_found)
8
8
  #
9
- # The message is sent to the client, so keep internal details out of
10
- # it. Any other exception becomes INTERNAL with a generic message
11
- # and is logged server-side.
9
+ # Optionally attach trailing metadata and rich error details (protobuf
10
+ # messages such as google.rpc.BadRequest, sent in the standard
11
+ # grpc-status-details-bin trailer as a google.rpc.Status):
12
+ #
13
+ # raise Tonic::Rails::RpcError.new(
14
+ # "invalid item", code: :invalid_argument,
15
+ # metadata: { "x-request-id" => request_id },
16
+ # details: [Google::Rpc::BadRequest.new(field_violations: [...])],
17
+ # )
18
+ #
19
+ # The message, metadata and details go to the client, so keep internal
20
+ # details out of them. Any other exception becomes INTERNAL with a
21
+ # generic message and is logged server-side.
12
22
  class RpcError < Error
13
23
  CODES = {
14
24
  ok: 0,
@@ -35,13 +45,70 @@ module Tonic
35
45
  # @return [Integer] numeric gRPC status code
36
46
  attr_reader :code
37
47
 
48
+ # @return [Hash] trailing metadata sent with the status
49
+ attr_reader :metadata
50
+
51
+ # @return [Array<Google::Protobuf::AbstractMessage>] rich error details
52
+ attr_reader :details
53
+
38
54
  # @param message [String] status message sent to the client
39
55
  # @param code [Symbol, Integer] gRPC status code (see CODES)
40
- def initialize(message = nil, code: :unknown)
56
+ # @param metadata [Hash] trailing metadata (see Tonic::Rails::Metadata)
57
+ # @param details [Array<Google::Protobuf::AbstractMessage>] messages
58
+ # packed into google.rpc.Status.details
59
+ def initialize(message = nil, code: :unknown, metadata: {}, details: [])
41
60
  @code = self.class.normalize_code(code)
61
+ @metadata = metadata
62
+ @details = Array(details)
42
63
  super(message || code_name.to_s)
43
64
  end
44
65
 
66
+ # @api private
67
+ # @return [Hash{String=>String}] trailers to send with this status
68
+ def trailers
69
+ trailers = Metadata.encode(metadata)
70
+ trailers["grpc-status-details-bin"] = [status_proto].pack("m0") unless details.empty?
71
+ trailers
72
+ end
73
+
74
+ # The encoded google.rpc.Status (code = 1, message = 2, details = 3 as
75
+ # google.protobuf.Any), built directly so googleapis protos aren't a
76
+ # dependency.
77
+ def status_proto
78
+ require "google/protobuf/well_known_types"
79
+ buf = (+"").b
80
+ buf << field_key(1, 0) << varint(code)
81
+ buf << length_delimited(2, message.b)
82
+ details.each do |detail|
83
+ any = detail.is_a?(Google::Protobuf::Any) ? detail : Google::Protobuf::Any.pack(detail)
84
+ buf << length_delimited(3, Google::Protobuf::Any.encode(any))
85
+ end
86
+ buf
87
+ end
88
+
89
+ private
90
+
91
+ def field_key(field, wire_type)
92
+ varint((field << 3) | wire_type)
93
+ end
94
+
95
+ def length_delimited(field, bytes)
96
+ field_key(field, 2) + varint(bytes.bytesize) + bytes
97
+ end
98
+
99
+ def varint(value)
100
+ bytes = []
101
+ loop do
102
+ byte = value & 0x7f
103
+ value >>= 7
104
+ bytes << (value.zero? ? byte : byte | 0x80)
105
+ break if value.zero?
106
+ end
107
+ bytes.pack("C*")
108
+ end
109
+
110
+ public
111
+
45
112
  # @return [Symbol] e.g. :not_found
46
113
  def code_name
47
114
  CODE_NAMES.fetch(@code)
@@ -20,8 +20,7 @@ module Tonic
20
20
  @router = Router.new
21
21
  @native = nil
22
22
  @state = :stopped
23
- @stop_reader = nil
24
- @stop_writer = nil
23
+ @stop_signal = nil
25
24
  @stop_requested = false
26
25
  @lock = Mutex.new
27
26
  end
@@ -51,12 +50,17 @@ module Tonic
51
50
  @configuration.validate!
52
51
  Tonic::Rails.native_extension!
53
52
  Tonic::Rails._set_shutting_down(false)
54
- @stop_reader, @stop_writer = IO.pipe
53
+ # A Queue rather than a self-pipe: pushing to it is safe from trap
54
+ # handlers, and unlike pipes it can be waited on with a timeout
55
+ # on every platform (Windows select() only handles sockets).
56
+ @stop_signal = Thread::Queue.new
55
57
  stop if @stop_requested # requested while we were starting up
56
58
  @native = build_native_server
57
59
  @native.start_server
60
+ log_pump = spawn_log_pump
58
61
  @state = :running
59
62
 
63
+ request_log = subscribe_request_logger
60
64
  previous_handlers = install_signal_handlers ? trap_signals : {}
61
65
  workers = Array.new(@configuration.dispatch_threads) { |i| spawn_worker(i) }
62
66
  log_startup
@@ -65,11 +69,12 @@ module Tonic
65
69
  wait_for_stop(workers)
66
70
  shutdown(workers)
67
71
  ensure
72
+ ActiveSupport::Notifications.unsubscribe(request_log) if request_log
68
73
  previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
69
74
  @native&.stop_server
75
+ stop_log_pump(log_pump)
70
76
  @native = nil
71
- [@stop_reader, @stop_writer].each { |io| io.close if io && !io.closed? }
72
- @stop_reader = @stop_writer = nil
77
+ @stop_signal = nil
73
78
  Tonic::Rails._set_shutting_down(false)
74
79
  @state = :stopped
75
80
  end
@@ -79,9 +84,7 @@ module Tonic
79
84
  # other threads; returns immediately.
80
85
  def stop
81
86
  @stop_requested = true
82
- @stop_writer&.write_nonblock(".", exception: false)
83
- rescue IOError
84
- # Already stopped.
87
+ @stop_signal&.push(:stop)
85
88
  end
86
89
 
87
90
  def running?
@@ -113,7 +116,6 @@ module Tonic
113
116
  native.set_worker_threads(config.worker_threads || 0)
114
117
  native.set_keepalive(config.keepalive_interval.to_f, config.keepalive_timeout.to_f)
115
118
  native.set_dispatch_queue_size(config.dispatch_queue_size)
116
- native.set_log_requests(config.log_requests ? true : false)
117
119
  native.set_compression(config.compression ? true : false)
118
120
  native.set_reflection(config.reflection ? true : false)
119
121
  native.set_reflection_descriptors(Reflection.file_descriptors(@router.service_names)) if config.reflection
@@ -133,8 +135,9 @@ module Tonic
133
135
  end
134
136
 
135
137
  guarded = lambda do |path, metadata, transport|
136
- call.call(path, metadata, transport)
137
- rescue RpcError
138
+ Instrumentation.instrument(path, metadata) { call.call(path, metadata, transport) }
139
+ rescue RpcError => e
140
+ attach_error_trailers(transport, e)
138
141
  raise
139
142
  rescue StandardError => e
140
143
  Tonic::Rails.log_exception(e, path)
@@ -144,6 +147,15 @@ module Tonic
144
147
  wrap_with_rails_executor(guarded)
145
148
  end
146
149
 
150
+ # An RpcError's own metadata and rich details travel as trailers.
151
+ def attach_error_trailers(transport, error)
152
+ return if error.metadata.empty? && error.details.empty?
153
+
154
+ transport[:sender]&.add_trailers(error.trailers)
155
+ rescue StandardError => e
156
+ Tonic::Rails.log_exception(e, "RpcError trailers")
157
+ end
158
+
147
159
  # Each RPC runs inside the Rails executor (or reloader, when code
148
160
  # reloading is on): ActiveRecord connections are returned, query
149
161
  # caches and CurrentAttributes are reset — the same isolation Rack
@@ -156,6 +168,41 @@ module Tonic
156
168
  ->(path, metadata, transport) { wrapper.wrap { callback.call(path, metadata, transport) } }
157
169
  end
158
170
 
171
+ def subscribe_request_logger
172
+ return unless @configuration.log_requests && @configuration.logger
173
+
174
+ Instrumentation::RequestLogger.new(@configuration.logger).subscribe
175
+ end
176
+
177
+ # Transport events from the Rust side (rejected calls, expired
178
+ # deadlines, refused connections, faults) can't call Ruby directly;
179
+ # this thread drains them into the configured logger.
180
+ def spawn_log_pump
181
+ native = @native
182
+ logger = @configuration.logger
183
+ @log_pump_running = true
184
+ Thread.new do
185
+ Thread.current.name = "tonic-rails-log"
186
+ write_transport_logs(logger, native.next_log_events(0.25)) while @log_pump_running
187
+ write_transport_logs(logger, native.next_log_events(0)) # whatever shutdown produced
188
+ rescue StandardError => e
189
+ warn("tonic-rails: log thread stopped: #{e.class}: #{e.message}")
190
+ end
191
+ end
192
+
193
+ def stop_log_pump(thread)
194
+ return unless thread
195
+
196
+ @log_pump_running = false
197
+ thread.join(2)
198
+ end
199
+
200
+ def write_transport_logs(logger, events)
201
+ return unless logger
202
+
203
+ events.each { |level, message| logger.public_send(level, "tonic-rails: #{message}") }
204
+ end
205
+
159
206
  def spawn_worker(index)
160
207
  native = @native
161
208
  Thread.new do
@@ -174,7 +221,7 @@ module Tonic
174
221
 
175
222
  def wait_for_stop(workers)
176
223
  loop do
177
- break if @stop_reader.wait_readable(1)
224
+ break if @stop_signal.pop(timeout: 1)
178
225
 
179
226
  dead = workers.reject(&:alive?)
180
227
  unless dead.empty?
@@ -28,19 +28,35 @@ module Tonic
28
28
  attr_reader :error
29
29
  end
30
30
 
31
- # Response side of a call: collects encoded messages.
31
+ # Response side of a call: collects encoded messages and metadata.
32
32
  class Sender
33
- attr_reader :messages
33
+ attr_reader :messages, :headers, :trailers
34
34
 
35
35
  def initialize
36
36
  @messages = []
37
+ @headers = {}
38
+ @trailers = {}
37
39
  end
38
40
 
39
41
  def push(bytes)
40
42
  @messages << bytes
41
43
  end
44
+
45
+ def send_headers(headers)
46
+ raise "response headers must be sent before any response message" unless @messages.empty?
47
+
48
+ @headers.merge!(headers)
49
+ end
50
+
51
+ def add_trailers(trailers)
52
+ @trailers.merge!(trailers)
53
+ end
42
54
  end
43
55
 
56
+ # What a call produced: the response (or responses, for streaming),
57
+ # plus the response headers and trailers as they'd go on the wire.
58
+ Result = Struct.new(:response, :headers, :trailers)
59
+
44
60
  module_function
45
61
 
46
62
  # @param service_class [Class] handler class
@@ -53,6 +69,18 @@ module Tonic
53
69
  # an Array of responses for server- and bidi-streaming RPCs
54
70
  # @raise [RpcError] when the handler ends the call with a status
55
71
  def call(service_class, rpc_name, request, metadata: {}, timeout_ms: nil)
72
+ invoke(service_class, rpc_name, request, metadata: metadata, timeout_ms: timeout_ms).response
73
+ end
74
+
75
+ # Like #call, but returns a Result with the response metadata too:
76
+ #
77
+ # result = Tonic::Rails::Testing.invoke(ItemService, :GetItem, { id: 1 })
78
+ # result.response.name # => "Widget"
79
+ # result.headers["x-cache"] # => "hit"
80
+ # result.trailers["x-cost-ms"] # => "3"
81
+ #
82
+ # @return [Result]
83
+ def invoke(service_class, rpc_name, request, metadata: {}, timeout_ms: nil)
56
84
  spec = service_class.grpc_rpcs.fetch(rpc_name.to_s) do
57
85
  raise ArgumentError, "#{service_class} has no RPC #{rpc_name}"
58
86
  end
@@ -69,11 +97,12 @@ module Tonic
69
97
  timeout_ms: timeout_ms,
70
98
  )
71
99
 
72
- if spec[:response_stream]
100
+ response = if spec[:response_stream]
73
101
  sender.messages.map { |bytes| spec[:response].decode(bytes) }
74
102
  else
75
103
  spec[:response].decode(result)
76
104
  end
105
+ Result.new(response, sender.headers, sender.trailers)
77
106
  end
78
107
 
79
108
  def encode(klass, message)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Tonic
4
4
  module Rails
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/tonic-rails.rb CHANGED
@@ -10,9 +10,11 @@ module Tonic
10
10
  end
11
11
 
12
12
  require "tonic/rails/version"
13
+ require "tonic/rails/metadata"
13
14
  require "tonic/rails/rpc_error"
14
15
  require "tonic/rails/configuration"
15
16
  require "tonic/rails/router"
17
+ require "tonic/rails/instrumentation"
16
18
  require "tonic/rails/base"
17
19
  require "tonic/rails/reflection"
18
20
  require "tonic/rails/server"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tonic-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - Matt Tanous
@@ -54,12 +54,15 @@ files:
54
54
  - CHANGELOG.md
55
55
  - LICENSE
56
56
  - README.md
57
+ - lib/puma/plugin/tonic_rails.rb
57
58
  - lib/tonic-rails.rb
58
59
  - lib/tonic/rails/3.3/tonic_rails_native.so
59
60
  - lib/tonic/rails/3.4/tonic_rails_native.so
60
61
  - lib/tonic/rails/4.0/tonic_rails_native.so
61
62
  - lib/tonic/rails/base.rb
62
63
  - lib/tonic/rails/configuration.rb
64
+ - lib/tonic/rails/instrumentation.rb
65
+ - lib/tonic/rails/metadata.rb
63
66
  - lib/tonic/rails/railtie.rb
64
67
  - lib/tonic/rails/reflection.rb
65
68
  - lib/tonic/rails/router.rb