tonic-rails 0.1.0-x86_64-linux-gnu → 0.3.0-x86_64-linux-gnu
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -2
- data/README.md +204 -13
- data/lib/generators/tonic_rails/install/install_generator.rb +45 -0
- data/lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt +21 -0
- data/lib/generators/tonic_rails/service/service_generator.rb +110 -0
- data/lib/generators/tonic_rails/service/templates/handler.rb.tt +38 -0
- data/lib/generators/tonic_rails/service/templates/service.proto.tt +16 -0
- data/lib/generators/tonic_rails/service/templates/service_spec.rb.tt +17 -0
- data/lib/generators/tonic_rails/service/templates/service_test.rb.tt +14 -0
- data/lib/puma/plugin/tonic_rails.rb +116 -0
- data/lib/tonic/rails/3.3/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/3.4/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/4.0/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/base.rb +169 -23
- data/lib/tonic/rails/configuration.rb +27 -4
- data/lib/tonic/rails/health.rb +105 -0
- data/lib/tonic/rails/instrumentation.rb +78 -0
- data/lib/tonic/rails/metadata.rb +50 -0
- data/lib/tonic/rails/peer.rb +60 -0
- data/lib/tonic/rails/railtie.rb +2 -2
- data/lib/tonic/rails/router.rb +1 -0
- data/lib/tonic/rails/rpc_error.rb +73 -6
- data/lib/tonic/rails/server.rb +82 -12
- data/lib/tonic/rails/tasks/tonic-rails.rake +13 -1
- data/lib/tonic/rails/testing/errors.rb +47 -0
- data/lib/tonic/rails/testing/minitest.rb +44 -0
- data/lib/tonic/rails/testing/rspec.rb +53 -0
- data/lib/tonic/rails/testing.rb +35 -4
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +4 -0
- metadata +16 -1
|
@@ -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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/lib/tonic/rails/base.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/callbacks"
|
|
4
|
+
require "active_support/rescuable"
|
|
3
5
|
require "active_support/core_ext/class/attribute"
|
|
4
6
|
require "active_support/core_ext/hash/keys"
|
|
5
7
|
require "active_support/core_ext/string/inflections"
|
|
@@ -25,8 +27,17 @@ module Tonic
|
|
|
25
27
|
# is set (fastest), or string-keyed Hashes by default. Handlers may
|
|
26
28
|
# return either a protobuf message or a Hash; streaming handlers
|
|
27
29
|
# return any Enumerable of them.
|
|
30
|
+
#
|
|
31
|
+
# Like controllers, handlers get callbacks and exception mapping:
|
|
32
|
+
#
|
|
33
|
+
# before_rpc :authenticate, except: :Health
|
|
34
|
+
# rescue_from ActiveRecord::RecordNotFound, code: :not_found
|
|
35
|
+
# rescue_from Pundit::NotAuthorizedError, with: :forbidden
|
|
28
36
|
module Base
|
|
29
37
|
def self.included(base)
|
|
38
|
+
base.include(ActiveSupport::Callbacks)
|
|
39
|
+
base.include(ActiveSupport::Rescuable)
|
|
40
|
+
base.define_callbacks(:rpc)
|
|
30
41
|
base.extend(ClassMethods)
|
|
31
42
|
base.class_attribute :grpc_service_name, instance_writer: false
|
|
32
43
|
base.class_attribute :grpc_rpcs, instance_writer: false, default: {}
|
|
@@ -34,6 +45,20 @@ module Tonic
|
|
|
34
45
|
end
|
|
35
46
|
|
|
36
47
|
module ClassMethods
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def add_rpc_callback(kind, methods, only, except, block)
|
|
51
|
+
only &&= rpc_name_matcher(only)
|
|
52
|
+
except &&= rpc_name_matcher(except)
|
|
53
|
+
condition = lambda do |handler|
|
|
54
|
+
name = handler.current_rpc.underscore
|
|
55
|
+
(only.nil? || only.include?(name)) && (except.nil? || !except.include?(name))
|
|
56
|
+
end
|
|
57
|
+
(methods + [block].compact).each { |callback| set_callback(:rpc, kind, callback, if: condition) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
public
|
|
61
|
+
|
|
37
62
|
# @param name [String] fully-qualified service name, e.g. "shop.ProductService"
|
|
38
63
|
def service(name)
|
|
39
64
|
self.grpc_service_name = name.to_s
|
|
@@ -97,6 +122,51 @@ module Tonic
|
|
|
97
122
|
end
|
|
98
123
|
end
|
|
99
124
|
|
|
125
|
+
# Run a method or block before each RPC. Raising (e.g. an RpcError)
|
|
126
|
+
# stops the call. The decoded `request` is available.
|
|
127
|
+
# @param only [Symbol, String, Array] RPC names to apply to
|
|
128
|
+
# @param except [Symbol, String, Array] RPC names to skip
|
|
129
|
+
def before_rpc(*methods, only: nil, except: nil, &block)
|
|
130
|
+
add_rpc_callback(:before, methods, only, except, block)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Run after each RPC that completes without raising (for streaming
|
|
134
|
+
# responses, after the last message is sent).
|
|
135
|
+
def after_rpc(*methods, only: nil, except: nil, &block)
|
|
136
|
+
add_rpc_callback(:after, methods, only, except, block)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Wrap each RPC; the method or block must yield to run it.
|
|
140
|
+
def around_rpc(*methods, only: nil, except: nil, &block)
|
|
141
|
+
add_rpc_callback(:around, methods, only, except, block)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Map exceptions raised during an RPC (including callbacks) to a
|
|
145
|
+
# gRPC status. Same as ActiveSupport::Rescuable, plus a `code:`
|
|
146
|
+
# shorthand:
|
|
147
|
+
#
|
|
148
|
+
# rescue_from ActiveRecord::RecordNotFound, code: :not_found
|
|
149
|
+
# rescue_from Stale, code: :aborted, message: "please retry"
|
|
150
|
+
# rescue_from Denied, with: :deny # def deny(e) = raise RpcError...
|
|
151
|
+
#
|
|
152
|
+
# With `code:`, the client receives `message` (a String, or a Proc
|
|
153
|
+
# given the exception), defaulting to the exception's own message.
|
|
154
|
+
# Handlers given via `with:` or a block must raise an RpcError.
|
|
155
|
+
def rescue_from(*klasses, with: nil, code: nil, message: nil, &)
|
|
156
|
+
return super(*klasses, with: with, &) unless code
|
|
157
|
+
|
|
158
|
+
RpcError.normalize_code(code) # fail at definition time on typos
|
|
159
|
+
super(*klasses) do |exception|
|
|
160
|
+
text = message.respond_to?(:call) ? message.call(exception) : (message || exception.message)
|
|
161
|
+
raise RpcError.new(text, code: code)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @api private
|
|
166
|
+
def rpc_name_matcher(names)
|
|
167
|
+
Array(names).map { |name| name.to_s.underscore }
|
|
168
|
+
end
|
|
169
|
+
|
|
100
170
|
# @api private Called by Server#register.
|
|
101
171
|
def register_on(router)
|
|
102
172
|
raise NotConfiguredError, "#{self} has no service name; call `service` or `rpc_service`" unless grpc_service_name
|
|
@@ -106,11 +176,38 @@ module Tonic
|
|
|
106
176
|
end
|
|
107
177
|
end
|
|
108
178
|
|
|
179
|
+
# The RPC being handled, as named in the .proto (e.g. "GetItem").
|
|
180
|
+
# @return [String]
|
|
181
|
+
attr_reader :current_rpc
|
|
182
|
+
|
|
183
|
+
# The client: address, and certificate with mutual TLS.
|
|
184
|
+
# @return [Tonic::Rails::Peer]
|
|
185
|
+
attr_reader :peer
|
|
186
|
+
|
|
187
|
+
# The decoded request: a message (or Hash), or for client-streaming
|
|
188
|
+
# RPCs an Enumerator of them. Also passed to the handler method.
|
|
189
|
+
attr_reader :request
|
|
190
|
+
|
|
109
191
|
# gRPC request metadata (lower-case header names). Binary (`-bin`)
|
|
110
192
|
# values are base64-encoded, as sent on the wire.
|
|
111
193
|
# @return [Hash{String=>String}]
|
|
112
194
|
attr_reader :grpc_metadata
|
|
113
195
|
|
|
196
|
+
# Response headers (initial metadata) for this call. For streaming
|
|
197
|
+
# responses, set them before the first message; for unary RPCs,
|
|
198
|
+
# anywhere in the handler. `-bin` names take binary values.
|
|
199
|
+
# @return [Hash]
|
|
200
|
+
def response_metadata
|
|
201
|
+
@response_metadata ||= {}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Trailing metadata, sent with the final status, whether the call
|
|
205
|
+
# succeeds or fails.
|
|
206
|
+
# @return [Hash]
|
|
207
|
+
def trailing_metadata
|
|
208
|
+
@trailing_metadata ||= {}
|
|
209
|
+
end
|
|
210
|
+
|
|
114
211
|
# Milliseconds left before the client's deadline, or nil if the
|
|
115
212
|
# client did not set one.
|
|
116
213
|
# @return [Integer, nil]
|
|
@@ -137,44 +234,74 @@ module Tonic
|
|
|
137
234
|
# (`#push`) for server-streaming RPCs.
|
|
138
235
|
# @return [String, nil] the encoded unary response, or nil when
|
|
139
236
|
# messages were streamed through `sender`
|
|
140
|
-
def call_rpc(rpc_name, metadata:, reader:, sender: nil, timeout_ms: nil)
|
|
237
|
+
def call_rpc(rpc_name, metadata:, reader:, sender: nil, timeout_ms: nil, peer: nil)
|
|
141
238
|
spec = self.class.grpc_rpcs[rpc_name.to_s]
|
|
142
239
|
raise RpcError.new("method not implemented", code: :unimplemented) unless spec
|
|
143
240
|
raise RpcError.new("method #{rpc_name} not implemented", code: :unimplemented) unless respond_to?(spec[:handler])
|
|
144
241
|
|
|
242
|
+
@current_rpc = rpc_name.to_s
|
|
243
|
+
@peer = peer || Peer.new
|
|
145
244
|
@grpc_metadata = metadata
|
|
245
|
+
@grpc_sender = sender
|
|
246
|
+
@grpc_headers_sent = false
|
|
146
247
|
@grpc_deadline_at = timeout_ms && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + (timeout_ms / 1000.0))
|
|
147
248
|
|
|
148
|
-
request =
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
249
|
+
@request = build_request(reader, spec)
|
|
250
|
+
response = nil
|
|
251
|
+
run_callbacks(:rpc) { response = respond(public_send(spec[:handler], @request), spec, sender) }
|
|
252
|
+
response
|
|
253
|
+
rescue RpcError
|
|
254
|
+
raise
|
|
255
|
+
rescue StandardError => e
|
|
256
|
+
rescue_exception(e)
|
|
257
|
+
ensure
|
|
258
|
+
attach_trailing_metadata
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
private
|
|
262
|
+
|
|
263
|
+
# rescue_from handlers must raise; anything they don't handle (or a
|
|
264
|
+
# handler that returns normally) becomes a logged INTERNAL error.
|
|
265
|
+
def rescue_exception(exception)
|
|
266
|
+
if rescue_with_handler(exception)
|
|
267
|
+
Tonic::Rails.log_exception(
|
|
268
|
+
TypeError.new("rescue_from handler for #{exception.class} returned instead of raising an RpcError"),
|
|
269
|
+
"#{self.class}##{current_rpc}",
|
|
270
|
+
)
|
|
154
271
|
else
|
|
155
|
-
|
|
272
|
+
Tonic::Rails.log_exception(exception, "#{self.class}##{current_rpc}")
|
|
156
273
|
end
|
|
274
|
+
raise RpcError.new("internal error", code: :internal)
|
|
275
|
+
end
|
|
157
276
|
|
|
158
|
-
|
|
277
|
+
def build_request(reader, spec)
|
|
278
|
+
unless spec[:request_stream]
|
|
279
|
+
return read_request(reader, spec) || raise(RpcError.new("missing request message", code: :invalid_argument))
|
|
280
|
+
end
|
|
159
281
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
282
|
+
Enumerator.new do |y|
|
|
283
|
+
while (message = read_request(reader, spec))
|
|
284
|
+
y << message
|
|
163
285
|
end
|
|
164
|
-
|
|
165
|
-
result.each { |item| push_response(sender, encode_response(item, spec)) }
|
|
166
|
-
nil
|
|
167
|
-
else
|
|
168
|
-
encode_response(result, spec)
|
|
169
286
|
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
287
|
end
|
|
176
288
|
|
|
177
|
-
|
|
289
|
+
# Unary: the encoded response. Streaming: nil, after pushing each
|
|
290
|
+
# message through the sender.
|
|
291
|
+
def respond(result, spec, sender)
|
|
292
|
+
unless spec[:response_stream]
|
|
293
|
+
encoded = encode_response(result, spec)
|
|
294
|
+
send_response_headers
|
|
295
|
+
return encoded
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
unless result.respond_to?(:each)
|
|
299
|
+
raise TypeError, "streaming handler #{self.class}##{spec[:handler]} must return an Enumerable, got #{result.class}"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
result.each { |item| push_response(sender, encode_response(item, spec)) }
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
178
305
|
|
|
179
306
|
def read_request(reader, spec)
|
|
180
307
|
bytes = reader.next
|
|
@@ -205,11 +332,30 @@ module Tonic
|
|
|
205
332
|
end
|
|
206
333
|
|
|
207
334
|
def push_response(sender, bytes)
|
|
335
|
+
send_response_headers
|
|
208
336
|
sender.push(bytes)
|
|
209
337
|
rescue IOError
|
|
210
338
|
# The client went away; nothing left to deliver to.
|
|
211
339
|
raise RpcError.new("client cancelled the stream", code: :cancelled)
|
|
212
340
|
end
|
|
341
|
+
|
|
342
|
+
def send_response_headers
|
|
343
|
+
return if @grpc_headers_sent
|
|
344
|
+
|
|
345
|
+
@grpc_headers_sent = true
|
|
346
|
+
return if @response_metadata.nil? || @response_metadata.empty? || @grpc_sender.nil?
|
|
347
|
+
|
|
348
|
+
@grpc_sender.send_headers(Metadata.encode(@response_metadata))
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Runs in `ensure`, so it must never raise over the call's outcome.
|
|
352
|
+
def attach_trailing_metadata
|
|
353
|
+
return if @trailing_metadata.nil? || @trailing_metadata.empty? || @grpc_sender.nil?
|
|
354
|
+
|
|
355
|
+
@grpc_sender.add_trailers(Metadata.encode(@trailing_metadata))
|
|
356
|
+
rescue StandardError => e
|
|
357
|
+
Tonic::Rails.log_exception(e, "#{self.class} trailing metadata")
|
|
358
|
+
end
|
|
213
359
|
end
|
|
214
360
|
end
|
|
215
361
|
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
|
|
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:
|
|
@@ -68,6 +69,17 @@ module Tonic
|
|
|
68
69
|
# HTTP/2 PING interval and ack timeout, in seconds. Defaults 15 / 5.
|
|
69
70
|
attr_accessor :keepalive_interval, :keepalive_timeout
|
|
70
71
|
|
|
72
|
+
# Callable checked every `health_check_interval` seconds (inside the
|
|
73
|
+
# Rails executor). While it returns false or raises, grpc.health.v1
|
|
74
|
+
# reports NOT_SERVING so readiness probes and load balancers route
|
|
75
|
+
# around this instance. Default nil (always SERVING while up).
|
|
76
|
+
#
|
|
77
|
+
# config.health_check = -> { ActiveRecord::Base.connection.active? }
|
|
78
|
+
attr_accessor :health_check
|
|
79
|
+
|
|
80
|
+
# Seconds between health checks. Default 5.
|
|
81
|
+
attr_accessor :health_check_interval
|
|
82
|
+
|
|
71
83
|
# Look handler classes up again on each request so code reloading
|
|
72
84
|
# works. The Railtie enables this when Rails reloading is on.
|
|
73
85
|
attr_accessor :reload_handlers
|
|
@@ -93,6 +105,8 @@ module Tonic
|
|
|
93
105
|
@keepalive_interval = 15
|
|
94
106
|
@keepalive_timeout = 5
|
|
95
107
|
@reload_handlers = false
|
|
108
|
+
@health_check = nil
|
|
109
|
+
@health_check_interval = 5
|
|
96
110
|
end
|
|
97
111
|
|
|
98
112
|
def tls?
|
|
@@ -112,15 +126,24 @@ module Tonic
|
|
|
112
126
|
raise ArgumentError, "max_message_size must be at most 4 GiB" if max_message_size > 0xFFFF_FFFF
|
|
113
127
|
raise ArgumentError, "tls_cert_path and tls_key_path must be set together" if tls_cert_path.nil? != tls_key_path.nil?
|
|
114
128
|
raise ArgumentError, "tls_client_ca_path requires tls_cert_path and tls_key_path" if tls_client_ca_path && !tls?
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
end
|
|
129
|
+
|
|
130
|
+
validate_callables!
|
|
118
131
|
|
|
119
132
|
self
|
|
120
133
|
end
|
|
121
134
|
|
|
122
135
|
private
|
|
123
136
|
|
|
137
|
+
def validate_callables!
|
|
138
|
+
raise ArgumentError, "health_check must respond to #call" if health_check && !health_check.respond_to?(:call)
|
|
139
|
+
unless health_check_interval.is_a?(Numeric) && health_check_interval.positive?
|
|
140
|
+
raise ArgumentError, "health_check_interval must be a positive number, got #{health_check_interval.inspect}"
|
|
141
|
+
end
|
|
142
|
+
return if interceptors.respond_to?(:each) && interceptors.all? { |i| i.respond_to?(:call) }
|
|
143
|
+
|
|
144
|
+
raise ArgumentError, "interceptors must be an Array of callables"
|
|
145
|
+
end
|
|
146
|
+
|
|
124
147
|
def positive_integer!(name)
|
|
125
148
|
value = public_send(name)
|
|
126
149
|
return if value.is_a?(Integer) && value.positive?
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tonic
|
|
4
|
+
module Rails
|
|
5
|
+
# What grpc.health.v1 reports, per service ("" = the whole server).
|
|
6
|
+
#
|
|
7
|
+
# A service reports SERVING only while the app hasn't marked it
|
|
8
|
+
# NOT_SERVING (Server#not_serving!) *and* the optional
|
|
9
|
+
# `config.health_check` passes. Neither overrides the other.
|
|
10
|
+
# @api private
|
|
11
|
+
class Health
|
|
12
|
+
def initialize(configuration, router)
|
|
13
|
+
@configuration = configuration
|
|
14
|
+
@router = router
|
|
15
|
+
@marks = {}
|
|
16
|
+
@check_passing = true
|
|
17
|
+
@native = nil
|
|
18
|
+
@checker = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def mark(services, serving)
|
|
22
|
+
names = services.empty? ? names_to_report : services.map(&:to_s)
|
|
23
|
+
names.each { |name| @marks[name] = serving }
|
|
24
|
+
publish(names)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Start reporting through a running native server.
|
|
28
|
+
def attach(native)
|
|
29
|
+
@native = native
|
|
30
|
+
@check_passing = true
|
|
31
|
+
publish(names_to_report)
|
|
32
|
+
start_checker
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Stop the checker before shutdown flips everything NOT_SERVING, so
|
|
36
|
+
# a passing check can't flip it back.
|
|
37
|
+
def detach
|
|
38
|
+
stop_checker
|
|
39
|
+
@native = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def names_to_report
|
|
45
|
+
["", *@router.service_names, *@marks.keys].uniq
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def publish(names)
|
|
49
|
+
native = @native
|
|
50
|
+
return unless native
|
|
51
|
+
|
|
52
|
+
names.each { |name| native.set_health(name, @check_passing && @marks.fetch(name, true)) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def start_checker
|
|
56
|
+
check = @configuration.health_check
|
|
57
|
+
return unless check
|
|
58
|
+
|
|
59
|
+
@wakeup = Thread::Queue.new
|
|
60
|
+
@checker = Thread.new do
|
|
61
|
+
Thread.current.name = "tonic-rails-health"
|
|
62
|
+
loop do
|
|
63
|
+
passing = run_check(check)
|
|
64
|
+
changed(passing) if passing != @check_passing
|
|
65
|
+
break if @wakeup.pop(timeout: @configuration.health_check_interval)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def stop_checker
|
|
71
|
+
return unless @checker
|
|
72
|
+
|
|
73
|
+
@wakeup << :stop
|
|
74
|
+
@checker.join(@configuration.drain_timeout.to_f + 5)
|
|
75
|
+
@checker = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def changed(passing)
|
|
79
|
+
@check_passing = passing
|
|
80
|
+
publish(names_to_report)
|
|
81
|
+
logger = @configuration.logger
|
|
82
|
+
return unless logger
|
|
83
|
+
|
|
84
|
+
if passing
|
|
85
|
+
logger.info("tonic-rails: health check passing; SERVING")
|
|
86
|
+
else
|
|
87
|
+
logger.warn("tonic-rails: health check failing; NOT_SERVING")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Runs inside the Rails executor so database connections are returned.
|
|
92
|
+
def run_check(check)
|
|
93
|
+
result = if defined?(::Rails.application) && ::Rails.application
|
|
94
|
+
::Rails.application.executor.wrap { check.call }
|
|
95
|
+
else
|
|
96
|
+
check.call
|
|
97
|
+
end
|
|
98
|
+
result ? true : false
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
Tonic::Rails.log_exception(e, "health check")
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -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
|