tonic-rails 0.1.0 → 0.2.0
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 +39 -2
- data/Cargo.lock +5 -5
- data/README.md +100 -11
- data/ext/tonic_rails_native/Cargo.toml +2 -2
- data/ext/tonic_rails_native/src/body.rs +113 -49
- data/ext/tonic_rails_native/src/dispatch.rs +44 -7
- data/ext/tonic_rails_native/src/framing.rs +50 -0
- data/ext/tonic_rails_native/src/gvl.rs +87 -21
- data/ext/tonic_rails_native/src/lib.rs +5 -1
- data/ext/tonic_rails_native/src/log.rs +131 -0
- data/ext/tonic_rails_native/src/net.rs +7 -1
- data/ext/tonic_rails_native/src/server.rs +46 -6
- data/lib/puma/plugin/tonic_rails.rb +116 -0
- data/lib/tonic/rails/base.rb +69 -22
- data/lib/tonic/rails/configuration.rb +2 -1
- data/lib/tonic/rails/instrumentation.rb +78 -0
- data/lib/tonic/rails/metadata.rb +50 -0
- data/lib/tonic/rails/railtie.rb +2 -2
- data/lib/tonic/rails/rpc_error.rb +73 -6
- data/lib/tonic/rails/server.rb +59 -12
- data/lib/tonic/rails/testing.rb +32 -3
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +2 -0
- metadata +5 -1
data/lib/tonic/rails/base.rb
CHANGED
|
@@ -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
|
-
|
|
149
|
-
|
|
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
|
|
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
|
data/lib/tonic/rails/railtie.rb
CHANGED
|
@@ -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.
|
|
32
|
-
#
|
|
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("
|
|
7
|
+
# raise Tonic::Rails::RpcError.new("item not found", code: :not_found)
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
|
|
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)
|
data/lib/tonic/rails/server.rb
CHANGED
|
@@ -20,8 +20,7 @@ module Tonic
|
|
|
20
20
|
@router = Router.new
|
|
21
21
|
@native = nil
|
|
22
22
|
@state = :stopped
|
|
23
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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 @
|
|
224
|
+
break if @stop_signal.pop(timeout: 1)
|
|
178
225
|
|
|
179
226
|
dead = workers.reject(&:alive?)
|
|
180
227
|
unless dead.empty?
|
data/lib/tonic/rails/testing.rb
CHANGED
|
@@ -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)
|
data/lib/tonic/rails/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Matt Tanous
|
|
@@ -77,11 +77,15 @@ files:
|
|
|
77
77
|
- ext/tonic_rails_native/src/framing.rs
|
|
78
78
|
- ext/tonic_rails_native/src/gvl.rs
|
|
79
79
|
- ext/tonic_rails_native/src/lib.rs
|
|
80
|
+
- ext/tonic_rails_native/src/log.rs
|
|
80
81
|
- ext/tonic_rails_native/src/net.rs
|
|
81
82
|
- ext/tonic_rails_native/src/server.rs
|
|
83
|
+
- lib/puma/plugin/tonic_rails.rb
|
|
82
84
|
- lib/tonic-rails.rb
|
|
83
85
|
- lib/tonic/rails/base.rb
|
|
84
86
|
- lib/tonic/rails/configuration.rb
|
|
87
|
+
- lib/tonic/rails/instrumentation.rb
|
|
88
|
+
- lib/tonic/rails/metadata.rb
|
|
85
89
|
- lib/tonic/rails/railtie.rb
|
|
86
90
|
- lib/tonic/rails/reflection.rb
|
|
87
91
|
- lib/tonic/rails/router.rb
|