tonic-rails 0.5.0 → 0.7.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 +44 -1
- data/Cargo.lock +69 -1
- data/README.md +265 -0
- data/ext/tonic_rails_native/Cargo.toml +7 -1
- data/ext/tonic_rails_native/src/body.rs +1 -40
- data/ext/tonic_rails_native/src/client.rs +665 -0
- data/ext/tonic_rails_native/src/dispatch.rs +10 -3
- data/ext/tonic_rails_native/src/gvl.rs +22 -7
- data/ext/tonic_rails_native/src/lib.rs +14 -0
- data/lib/tonic/rails/base.rb +4 -1
- data/lib/tonic/rails/client/retry_policy.rb +73 -0
- data/lib/tonic/rails/client.rb +460 -0
- data/lib/tonic/rails/instrumentation.rb +21 -5
- data/lib/tonic/rails/rpc_error.rb +61 -0
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +1 -0
- metadata +4 -1
|
@@ -20,7 +20,7 @@ use magnus::{
|
|
|
20
20
|
Error, RHash, RString, Ruby, Value, prelude::*, r_hash::ForEach, typed_data::DataTypeFunctions,
|
|
21
21
|
value::Opaque,
|
|
22
22
|
};
|
|
23
|
-
use parking_lot::Mutex;
|
|
23
|
+
use parking_lot::{Condvar, Mutex};
|
|
24
24
|
use rb_sys::{RB_NOGVL_INTR_FAIL, rb_nogvl, rb_thread_call_with_gvl};
|
|
25
25
|
use tokio::sync::{Notify, mpsc};
|
|
26
26
|
|
|
@@ -100,25 +100,40 @@ where
|
|
|
100
100
|
|
|
101
101
|
/// Minimal single-future executor that parks the current (non-tokio)
|
|
102
102
|
/// thread. tokio's channel and `Notify` futures are runtime-agnostic.
|
|
103
|
+
///
|
|
104
|
+
/// It parks on a pthread condition variable rather than
|
|
105
|
+
/// `std::thread::park`, which on macOS uses libdispatch: libdispatch
|
|
106
|
+
/// traps in the child of a multi-threaded process, and the client blocks
|
|
107
|
+
/// here in processes forked after it made calls.
|
|
103
108
|
pub fn block_on<F: Future>(fut: F) -> F::Output {
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
#[derive(Default)]
|
|
110
|
+
struct Signal {
|
|
111
|
+
woken: Mutex<bool>,
|
|
112
|
+
condvar: Condvar,
|
|
113
|
+
}
|
|
114
|
+
impl Wake for Signal {
|
|
106
115
|
fn wake(self: Arc<Self>) {
|
|
107
|
-
self.
|
|
116
|
+
self.wake_by_ref();
|
|
108
117
|
}
|
|
109
118
|
fn wake_by_ref(self: &Arc<Self>) {
|
|
110
|
-
self.
|
|
119
|
+
*self.woken.lock() = true;
|
|
120
|
+
self.condvar.notify_one();
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
123
|
|
|
114
|
-
let
|
|
124
|
+
let signal = Arc::new(Signal::default());
|
|
125
|
+
let waker = Waker::from(signal.clone());
|
|
115
126
|
let mut cx = Context::from_waker(&waker);
|
|
116
127
|
let mut fut = std::pin::pin!(fut);
|
|
117
128
|
loop {
|
|
118
129
|
if let Poll::Ready(value) = fut.as_mut().poll(&mut cx) {
|
|
119
130
|
return value;
|
|
120
131
|
}
|
|
121
|
-
|
|
132
|
+
let mut woken = signal.woken.lock();
|
|
133
|
+
while !*woken {
|
|
134
|
+
signal.condvar.wait(&mut woken);
|
|
135
|
+
}
|
|
136
|
+
*woken = false;
|
|
122
137
|
}
|
|
123
138
|
}
|
|
124
139
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
//! Modules:
|
|
21
21
|
//! - [`framing`] — gRPC wire format (frames, gzip, timeouts, trailers)
|
|
22
22
|
//! - [`body`] — response bodies and request stats
|
|
23
|
+
//! - [`client`] — `TonicRails::Native::Channel` and `ResponseStream`, for outgoing calls
|
|
23
24
|
//! - [`dispatch`] — the fallback tower service that queues RPCs for Ruby
|
|
24
25
|
//! - [`gvl`] — GVL release/acquire and the Ruby stream objects
|
|
25
26
|
//! - [`log`] — transport log events for Ruby's logger
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
//! - [`server`] — `TonicRails::Native::Server`
|
|
28
29
|
|
|
29
30
|
mod body;
|
|
31
|
+
mod client;
|
|
30
32
|
mod dispatch;
|
|
31
33
|
mod framing;
|
|
32
34
|
mod gvl;
|
|
@@ -36,6 +38,7 @@ mod server;
|
|
|
36
38
|
|
|
37
39
|
use magnus::{Error, Ruby, function, method, prelude::*};
|
|
38
40
|
|
|
41
|
+
use client::{NativeChannel, ResponseStream};
|
|
39
42
|
use gvl::{RequestReader, StreamSender};
|
|
40
43
|
use server::NativeServer;
|
|
41
44
|
|
|
@@ -95,5 +98,16 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
95
98
|
reader.define_method("next", method!(RequestReader::next, 0))?;
|
|
96
99
|
reader.define_method("error", method!(RequestReader::error, 0))?;
|
|
97
100
|
|
|
101
|
+
let channel = native.define_class("Channel", ruby.class_object())?;
|
|
102
|
+
channel.define_singleton_method("new", function!(NativeChannel::new, 4))?;
|
|
103
|
+
channel.define_method("unary", method!(NativeChannel::unary, 4))?;
|
|
104
|
+
channel.define_method("server_streaming", method!(NativeChannel::server_streaming, 4))?;
|
|
105
|
+
|
|
106
|
+
let stream = native.define_class("ResponseStream", ruby.class_object())?;
|
|
107
|
+
stream.undef_default_alloc_func();
|
|
108
|
+
stream.define_method("next", method!(ResponseStream::next, 0))?;
|
|
109
|
+
stream.define_method("result", method!(ResponseStream::result, 0))?;
|
|
110
|
+
stream.define_method("close", method!(ResponseStream::close, 0))?;
|
|
111
|
+
|
|
98
112
|
Ok(())
|
|
99
113
|
}
|
data/lib/tonic/rails/base.rb
CHANGED
|
@@ -248,7 +248,10 @@ module Tonic
|
|
|
248
248
|
|
|
249
249
|
@request = build_request(reader, spec)
|
|
250
250
|
response = nil
|
|
251
|
-
|
|
251
|
+
# Calls this handler makes with Tonic::Rails::Client default to its deadline.
|
|
252
|
+
Client.with_deadline(@grpc_deadline_at) do
|
|
253
|
+
run_callbacks(:rpc) { response = respond(public_send(spec[:handler], @request), spec, sender) }
|
|
254
|
+
end
|
|
252
255
|
response
|
|
253
256
|
rescue RpcError
|
|
254
257
|
raise
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tonic
|
|
4
|
+
module Rails
|
|
5
|
+
class Client
|
|
6
|
+
# When and how often a call is retried, following gRPC's retry design
|
|
7
|
+
# (A6). Pass one, a Hash of its fields or `true` for the defaults as
|
|
8
|
+
# `retries:` to Client.new or to a single call.
|
|
9
|
+
#
|
|
10
|
+
# Tonic::Rails::Client.new("items.internal:50051", retries: true)
|
|
11
|
+
# Tonic::Rails::Client.new("items.internal:50051", retries: { max_attempts: 4 })
|
|
12
|
+
# items.get_item({ id: 1 }, retries: false)
|
|
13
|
+
#
|
|
14
|
+
# Only calls the server hasn't started on are retried: the connection
|
|
15
|
+
# failed, or the server answered with a status and no response
|
|
16
|
+
# headers. Each wait is random, up to a cap that grows from
|
|
17
|
+
# `initial_backoff` by `multiplier` per attempt to `max_backoff`.
|
|
18
|
+
# Retrying stops when the next attempt couldn't start before the
|
|
19
|
+
# call's deadline.
|
|
20
|
+
RetryPolicy = Data.define(:max_attempts, :codes, :initial_backoff, :max_backoff, :multiplier) do
|
|
21
|
+
# Defaults for the fields left out.
|
|
22
|
+
def self.defaults
|
|
23
|
+
{ max_attempts: 3, codes: [:unavailable], initial_backoff: 0.1, max_backoff: 1, multiplier: 2 }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param option [RetryPolicy, Hash, true, false, nil]
|
|
27
|
+
# @return [RetryPolicy, nil] nil for no retries
|
|
28
|
+
def self.from(option)
|
|
29
|
+
case option
|
|
30
|
+
when nil, false then nil
|
|
31
|
+
when true then new(**defaults)
|
|
32
|
+
when Hash then new(**defaults, **option)
|
|
33
|
+
when self then option
|
|
34
|
+
else raise ArgumentError, "retries must be true, false, a Hash or a #{name}, got #{option.class}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param max_attempts [Integer] attempts in all, from 2 to 5. gRPC
|
|
39
|
+
# caps it at 5, so a misconfigured client can't multiply an
|
|
40
|
+
# outage's traffic by more than that.
|
|
41
|
+
# @param codes [Array<Symbol, Integer>] status codes worth retrying
|
|
42
|
+
# @param initial_backoff [Numeric] seconds; the cap on the first wait
|
|
43
|
+
# @param max_backoff [Numeric] seconds; the cap on any wait
|
|
44
|
+
# @param multiplier [Numeric] how much the cap grows per attempt
|
|
45
|
+
def initialize(max_attempts:, codes:, initial_backoff:, max_backoff:, multiplier:)
|
|
46
|
+
unless max_attempts.is_a?(Integer) && max_attempts.between?(2, 5)
|
|
47
|
+
raise ArgumentError, "max_attempts must be an Integer from 2 to 5"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
codes = Array(codes).map { |code| RpcError.normalize_code(code) }.uniq.freeze
|
|
51
|
+
raise ArgumentError, "codes must name at least one status code other than OK" if codes.empty? || codes.include?(0)
|
|
52
|
+
|
|
53
|
+
[[:initial_backoff, initial_backoff], [:max_backoff, max_backoff]].each do |field, value|
|
|
54
|
+
raise ArgumentError, "#{field} must be a positive number of seconds" unless value.is_a?(Numeric) && value.positive?
|
|
55
|
+
end
|
|
56
|
+
raise ArgumentError, "multiplier must be at least 1" unless multiplier.is_a?(Numeric) && multiplier >= 1
|
|
57
|
+
|
|
58
|
+
super
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param code [Integer]
|
|
62
|
+
def retry?(code)
|
|
63
|
+
codes.include?(code)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Seconds to wait after failed attempt number `attempt` (from 1).
|
|
67
|
+
def backoff(attempt)
|
|
68
|
+
rand * [initial_backoff * (multiplier**(attempt - 1)), max_backoff].min
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/inflector"
|
|
4
|
+
|
|
5
|
+
module Tonic
|
|
6
|
+
module Rails
|
|
7
|
+
# Calls other gRPC services, without the grpc gem.
|
|
8
|
+
#
|
|
9
|
+
# ITEMS = Tonic::Rails::Client.new("items.internal:50051", deadline: 2)
|
|
10
|
+
#
|
|
11
|
+
# items = ITEMS.stub("demo.v1.ItemService")
|
|
12
|
+
# item = items.get_item(Demo::V1::GetItemRequest.new(id: 1))
|
|
13
|
+
# item = items.get_item(id: 1)
|
|
14
|
+
# item = items.get_item({ id: 1 }, deadline: 0.5, metadata: { "x-request-id" => request_id })
|
|
15
|
+
# items.list_items(category: "books").each { |item| ... }
|
|
16
|
+
#
|
|
17
|
+
# Retries are opt-in: `retries: true` (or a Hash; see RetryPolicy) on
|
|
18
|
+
# the client or a call retries calls the server never started on.
|
|
19
|
+
#
|
|
20
|
+
# Stubs are built from the protobuf descriptors that
|
|
21
|
+
# `tonic_rails:compile_protos` (protoc --ruby_out) generates; no
|
|
22
|
+
# `_services_pb.rb` is needed. A failed call raises
|
|
23
|
+
# Tonic::Rails::RpcError.
|
|
24
|
+
#
|
|
25
|
+
# A client holds one HTTP/2 connection and is safe to share between
|
|
26
|
+
# threads, so create it once (a constant or an initializer). It keeps
|
|
27
|
+
# working across `fork`: each process connects on its own, with no
|
|
28
|
+
# hooks to call.
|
|
29
|
+
class Client
|
|
30
|
+
# A response with its headers and trailers; see `return_call:`.
|
|
31
|
+
Call = Data.define(:response, :headers, :trailers)
|
|
32
|
+
|
|
33
|
+
DEFAULT_MAX_MESSAGE_SIZE = 4 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
# Trailer a server sends to ask for a retry after so many
|
|
36
|
+
# milliseconds, or (with a negative value) for none.
|
|
37
|
+
RETRY_PUSHBACK = "grpc-retry-pushback-ms"
|
|
38
|
+
private_constant :RETRY_PUSHBACK
|
|
39
|
+
|
|
40
|
+
# @return [String] "host:port"
|
|
41
|
+
attr_reader :address
|
|
42
|
+
|
|
43
|
+
# @return [Numeric, nil] default deadline in seconds
|
|
44
|
+
attr_reader :deadline
|
|
45
|
+
|
|
46
|
+
# @return [RetryPolicy, nil] how calls are retried, unless they say otherwise
|
|
47
|
+
attr_reader :retries
|
|
48
|
+
|
|
49
|
+
# @param address [String] "host:port"
|
|
50
|
+
# @param tls [Boolean] false for plaintext, which must be explicit
|
|
51
|
+
# @param tls_ca_path [String, nil] CA certificates (PEM) to trust instead
|
|
52
|
+
# of the system's root certificates
|
|
53
|
+
# @param tls_cert_path [String, nil] client certificate (PEM), for mutual TLS
|
|
54
|
+
# @param tls_key_path [String, nil] client private key (PEM), for mutual TLS
|
|
55
|
+
# @param tls_server_name [String, nil] name to verify the server's
|
|
56
|
+
# certificate against, if not the host in `address`
|
|
57
|
+
# @param deadline [Numeric, nil] seconds each call may take, unless the
|
|
58
|
+
# call sets its own
|
|
59
|
+
# @param metadata [Hash] sent with every call (see Tonic::Rails::Metadata)
|
|
60
|
+
# @param max_message_size [Integer] largest message sent or received, in bytes
|
|
61
|
+
# @param connect_timeout [Numeric] seconds to wait for a connection
|
|
62
|
+
# @param retries [RetryPolicy, Hash, Boolean] retry calls the server
|
|
63
|
+
# never started on (see RetryPolicy); off by default
|
|
64
|
+
# @param logger [Logger, nil] where retries are logged; defaults to
|
|
65
|
+
# Tonic::Rails.configuration.logger
|
|
66
|
+
def initialize(address, tls: true, tls_ca_path: nil, tls_cert_path: nil, tls_key_path: nil,
|
|
67
|
+
tls_server_name: nil, deadline: nil, metadata: {}, max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
|
|
68
|
+
connect_timeout: 5, retries: false, logger: nil)
|
|
69
|
+
Tonic::Rails.native_extension!
|
|
70
|
+
validate_address!(address)
|
|
71
|
+
validate_tls!(tls, tls_cert_path, tls_key_path)
|
|
72
|
+
positive!(:deadline, deadline) unless deadline.nil?
|
|
73
|
+
positive!(:connect_timeout, connect_timeout)
|
|
74
|
+
unless max_message_size.is_a?(Integer) && max_message_size.positive?
|
|
75
|
+
raise ArgumentError, "max_message_size must be a positive Integer"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@address = address
|
|
79
|
+
@deadline = deadline
|
|
80
|
+
@retries = RetryPolicy.from(retries)
|
|
81
|
+
@logger = logger
|
|
82
|
+
@metadata = Metadata.encode(metadata)
|
|
83
|
+
@stubs = {}
|
|
84
|
+
tls_options = [read_pem(tls_ca_path, "CA"), read_pem(tls_cert_path, "certificate"),
|
|
85
|
+
read_pem(tls_key_path, "key"), tls_server_name]
|
|
86
|
+
@native = TonicRails::Native::Channel.new(
|
|
87
|
+
"#{tls ? 'https' : 'http'}://#{address}", max_message_size, (connect_timeout * 1000).ceil,
|
|
88
|
+
(tls_options if tls)
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A stub with one method per RPC of the service, named in snake_case
|
|
93
|
+
# (`GetItem` becomes `get_item`).
|
|
94
|
+
#
|
|
95
|
+
# @param service [String, Google::Protobuf::ServiceDescriptor, #service_name]
|
|
96
|
+
# the fully-qualified service name, its descriptor, or a grpc gem
|
|
97
|
+
# service class (`Demo::V1::ItemService::Service`)
|
|
98
|
+
# @return [Tonic::Rails::Client::Stub]
|
|
99
|
+
def stub(service)
|
|
100
|
+
descriptor = service_descriptor(service)
|
|
101
|
+
@stubs[descriptor.name] ||= Stub.for(descriptor).new(self)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def inspect
|
|
105
|
+
"#<#{self.class.name} #{address}>"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The deadline of the RPC this thread is handling, as a monotonic
|
|
109
|
+
# clock time; outgoing calls default to it.
|
|
110
|
+
# @api private
|
|
111
|
+
def self.current_deadline
|
|
112
|
+
Thread.current[:tonic_rails_deadline]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @api private
|
|
116
|
+
def self.with_deadline(deadline)
|
|
117
|
+
previous = Thread.current[:tonic_rails_deadline]
|
|
118
|
+
Thread.current[:tonic_rails_deadline] = deadline
|
|
119
|
+
yield
|
|
120
|
+
ensure
|
|
121
|
+
Thread.current[:tonic_rails_deadline] = previous
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Make a unary call. Stubs call this; use them instead.
|
|
125
|
+
# @api private
|
|
126
|
+
def unary(path, input, output, request, deadline: nil, metadata: {}, return_call: false, retries: nil)
|
|
127
|
+
bytes = input.encode(request_message(path, input, request))
|
|
128
|
+
metadata = call_metadata(metadata)
|
|
129
|
+
deadline_at = deadline_at(deadline)
|
|
130
|
+
policy = retry_policy(retries)
|
|
131
|
+
|
|
132
|
+
Instrumentation.instrument_request(path, address, metadata) do |payload|
|
|
133
|
+
code, error_message, response, headers, trailers, details = with_retries(path, policy, deadline_at, payload) do
|
|
134
|
+
@native.unary(path, bytes, metadata, remaining_ms(deadline_at))
|
|
135
|
+
end
|
|
136
|
+
headers = decode_metadata(headers)
|
|
137
|
+
trailers = decode_metadata(trailers)
|
|
138
|
+
check_status!(code, error_message, trailers, details)
|
|
139
|
+
|
|
140
|
+
response = decode_response(output, response, path)
|
|
141
|
+
return_call ? Call.new(response: response, headers: headers, trailers: trailers) : response
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Start a server-streaming call. Stubs call this; use them instead.
|
|
146
|
+
# @api private
|
|
147
|
+
def server_streaming(path, input, output, request, deadline: nil, metadata: {}, retries: nil)
|
|
148
|
+
bytes = input.encode(request_message(path, input, request))
|
|
149
|
+
metadata = call_metadata(metadata)
|
|
150
|
+
# Fixed now, so the stream keeps the deadline of the RPC this
|
|
151
|
+
# thread is handling even if it's read later.
|
|
152
|
+
deadline_at = deadline_at(deadline)
|
|
153
|
+
policy = retry_policy(retries)
|
|
154
|
+
|
|
155
|
+
ServerStream.new(path) do |stream, block|
|
|
156
|
+
Instrumentation.instrument_request(path, address, metadata) do |payload|
|
|
157
|
+
# A message arrives only after the response headers, which
|
|
158
|
+
# commit the call, so an attempt that yielded anything is never
|
|
159
|
+
# retried.
|
|
160
|
+
code, error_message, _, headers, trailers, details = with_retries(path, policy, deadline_at, payload) do
|
|
161
|
+
read_stream(@native.server_streaming(path, bytes, metadata, remaining_ms(deadline_at)), output, path, &block)
|
|
162
|
+
end
|
|
163
|
+
trailers = decode_metadata(trailers)
|
|
164
|
+
stream.finish(decode_metadata(headers), trailers)
|
|
165
|
+
check_status!(code, error_message, trailers, details)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def validate_address!(address)
|
|
173
|
+
return if address.is_a?(String) && address.match?(%r{\A[^\s/]+:\d+\z})
|
|
174
|
+
|
|
175
|
+
raise ArgumentError, "address must be \"host:port\", got #{address.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def validate_tls!(tls, cert_path, key_path)
|
|
179
|
+
raise ArgumentError, "tls must be true or false" unless [true, false].include?(tls)
|
|
180
|
+
raise ArgumentError, "tls_cert_path and tls_key_path must be set together" if cert_path.nil? != key_path.nil?
|
|
181
|
+
raise ArgumentError, "tls_cert_path and tls_key_path need tls: true" if cert_path && !tls
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def positive!(name, value)
|
|
185
|
+
raise ArgumentError, "#{name} must be a positive number of seconds" unless value.is_a?(Numeric) && value.positive?
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def read_pem(path, what)
|
|
189
|
+
return unless path
|
|
190
|
+
raise ArgumentError, "TLS #{what} file not found: #{path}" unless File.file?(path)
|
|
191
|
+
|
|
192
|
+
File.read(path)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def service_descriptor(service)
|
|
196
|
+
return service if service.is_a?(Google::Protobuf::ServiceDescriptor)
|
|
197
|
+
|
|
198
|
+
name = service.respond_to?(:service_name) ? service.service_name : service.to_s
|
|
199
|
+
descriptor = Google::Protobuf::DescriptorPool.generated_pool.lookup(name)
|
|
200
|
+
return descriptor if descriptor.is_a?(Google::Protobuf::ServiceDescriptor)
|
|
201
|
+
|
|
202
|
+
raise NotConfiguredError,
|
|
203
|
+
"#{name} is not a known protobuf service. Require its generated _pb.rb file (protoc --ruby_out) first."
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def request_message(path, input, request)
|
|
207
|
+
case request
|
|
208
|
+
when input then request
|
|
209
|
+
when Hash then input.new(request)
|
|
210
|
+
else raise TypeError, "#{path} expects a #{input} or Hash, got #{request.class}"
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def call_metadata(metadata)
|
|
215
|
+
metadata.empty? ? @metadata : @metadata.merge(Metadata.encode(metadata))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# When the call must end, as a monotonic clock time: the call's own
|
|
219
|
+
# deadline, or else the earlier of the client's default and the
|
|
220
|
+
# deadline of the RPC being handled.
|
|
221
|
+
def deadline_at(deadline)
|
|
222
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
223
|
+
case deadline
|
|
224
|
+
when nil then [(now + @deadline if @deadline), self.class.current_deadline].compact.min
|
|
225
|
+
when Numeric then now + deadline
|
|
226
|
+
when Time then now + (deadline - Time.now)
|
|
227
|
+
else raise ArgumentError, "deadline must be seconds (Numeric) or a Time, got #{deadline.class}"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Milliseconds left until `deadline_at`, or nil for no deadline.
|
|
232
|
+
def remaining_ms(deadline_at)
|
|
233
|
+
return unless deadline_at
|
|
234
|
+
|
|
235
|
+
remaining = deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
236
|
+
raise RpcError.new("deadline exceeded", code: :deadline_exceeded) unless remaining.positive?
|
|
237
|
+
|
|
238
|
+
(remaining * 1000).ceil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def retry_policy(retries)
|
|
242
|
+
retries.nil? ? @retries : RetryPolicy.from(retries)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Yield each message of a native stream, then return its result.
|
|
246
|
+
def read_stream(native, output, path)
|
|
247
|
+
while (bytes = native.next)
|
|
248
|
+
yield decode_response(output, bytes, path)
|
|
249
|
+
end
|
|
250
|
+
native.result
|
|
251
|
+
ensure
|
|
252
|
+
native.close
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Run attempts of a call (the block returns a native result) until one
|
|
256
|
+
# succeeds or shouldn't be retried; return its result.
|
|
257
|
+
def with_retries(path, policy, deadline_at, payload)
|
|
258
|
+
attempt = 1
|
|
259
|
+
loop do
|
|
260
|
+
payload[:attempts] = attempt if payload
|
|
261
|
+
result = yield
|
|
262
|
+
delay = retry_delay(policy, attempt, result, deadline_at)
|
|
263
|
+
if delay.nil?
|
|
264
|
+
log_gave_up(path, attempt, result) if attempt > 1 && !result[0].zero?
|
|
265
|
+
return result
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
log_retry(path, attempt, policy.max_attempts, result, delay)
|
|
269
|
+
sleep(delay)
|
|
270
|
+
attempt += 1
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Seconds to wait before retrying, or nil not to retry. The server
|
|
275
|
+
# can ask for a delay, or for no retry, with grpc-retry-pushback-ms.
|
|
276
|
+
def retry_delay(policy, attempt, result, deadline_at)
|
|
277
|
+
code, _, _, _, trailers, _, committed = result
|
|
278
|
+
return if policy.nil? || code.zero? || committed || attempt >= policy.max_attempts || !policy.retry?(code)
|
|
279
|
+
|
|
280
|
+
pushback = trailers.assoc(RETRY_PUSHBACK)&.last
|
|
281
|
+
delay = if pushback.nil? then policy.backoff(attempt)
|
|
282
|
+
elsif pushback.match?(/\A\d+\z/) then pushback.to_i / 1000.0
|
|
283
|
+
end
|
|
284
|
+
return if delay.nil?
|
|
285
|
+
return if deadline_at && Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay >= deadline_at
|
|
286
|
+
|
|
287
|
+
delay
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def logger
|
|
291
|
+
@logger || Tonic::Rails.configuration.logger
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def log_retry(path, attempt, max_attempts, result, delay)
|
|
295
|
+
logger&.warn(format("tonic-rails: %<path>s failed with %<status>s (attempt %<attempt>d of %<max>d); " \
|
|
296
|
+
"retrying in %<ms>dms", path: path, status: status_text(result), attempt: attempt,
|
|
297
|
+
max: max_attempts, ms: (delay * 1000).round))
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def log_gave_up(path, attempts, result)
|
|
301
|
+
logger&.warn("tonic-rails: #{path} failed with #{status_text(result)} after #{attempts} attempts")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def status_text(result)
|
|
305
|
+
code, message = result
|
|
306
|
+
text = RpcError::CODE_NAMES.fetch(code, code).to_s.upcase
|
|
307
|
+
message.empty? ? text : "#{text}: #{message}"
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def check_status!(code, message, trailers, details)
|
|
311
|
+
return if code.zero?
|
|
312
|
+
|
|
313
|
+
raise RpcError.new(message.empty? ? nil : message, code: code, metadata: trailers,
|
|
314
|
+
details: RpcError.decode_details(details))
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Wire-form [name, value] pairs to a Hash: `-bin` values decoded,
|
|
318
|
+
# repeated names collected into an Array, and transport headers
|
|
319
|
+
# (content-type, grpc-*) dropped.
|
|
320
|
+
def decode_metadata(pairs)
|
|
321
|
+
pairs.each_with_object({}) do |(name, value), metadata|
|
|
322
|
+
next if name.start_with?("grpc-") || Metadata::RESERVED.include?(name)
|
|
323
|
+
|
|
324
|
+
value = value.unpack1("m") if name.end_with?("-bin")
|
|
325
|
+
metadata[name] = metadata.key?(name) ? [*metadata[name], value] : value
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def decode_response(output, bytes, path)
|
|
330
|
+
output.decode(bytes)
|
|
331
|
+
rescue Google::Protobuf::ParseError
|
|
332
|
+
raise RpcError.new("invalid protobuf response from #{path}", code: :internal)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Calls an RPC's methods. Built per service from its descriptor; get
|
|
336
|
+
# one with Client#stub.
|
|
337
|
+
class Stub
|
|
338
|
+
class << self
|
|
339
|
+
# @api private
|
|
340
|
+
attr_reader :service_name
|
|
341
|
+
|
|
342
|
+
# @api private
|
|
343
|
+
def for(descriptor)
|
|
344
|
+
Class.new(self) do
|
|
345
|
+
@service_name = descriptor.name
|
|
346
|
+
descriptor.each { |method| define_rpc(method) }
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def inspect
|
|
351
|
+
service_name ? "#{Stub.name}(#{service_name})" : super
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
private
|
|
355
|
+
|
|
356
|
+
def define_rpc(method)
|
|
357
|
+
path = "/#{service_name}/#{method.name}"
|
|
358
|
+
name = ActiveSupport::Inflector.underscore(method.name)
|
|
359
|
+
if method.client_streaming
|
|
360
|
+
define_method(name) do |*, **|
|
|
361
|
+
raise NotImplementedError,
|
|
362
|
+
"#{path} is a client-streaming RPC; the tonic-rails client makes unary and server-streaming calls"
|
|
363
|
+
end
|
|
364
|
+
return
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
input = method.input_type.msgclass
|
|
368
|
+
output = method.output_type.msgclass
|
|
369
|
+
if method.server_streaming
|
|
370
|
+
define_method(name) do |request = nil, deadline: nil, metadata: {}, retries: nil, **fields|
|
|
371
|
+
request = rpc_request(name, request, fields)
|
|
372
|
+
@client.server_streaming(path, input, output, request, deadline: deadline, metadata: metadata,
|
|
373
|
+
retries: retries)
|
|
374
|
+
end
|
|
375
|
+
else
|
|
376
|
+
# `get_item(id: 1)` works as well as `get_item({ id: 1 })`.
|
|
377
|
+
define_method(name) do |request = nil, deadline: nil, metadata: {}, return_call: false, retries: nil,
|
|
378
|
+
**fields|
|
|
379
|
+
request = rpc_request(name, request, fields)
|
|
380
|
+
@client.unary(path, input, output, request, deadline: deadline, metadata: metadata,
|
|
381
|
+
return_call: return_call, retries: retries)
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# @return [Tonic::Rails::Client]
|
|
388
|
+
attr_reader :client
|
|
389
|
+
|
|
390
|
+
def initialize(client)
|
|
391
|
+
@client = client
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def inspect
|
|
395
|
+
"#<#{self.class.inspect} #{client.address}>"
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
private
|
|
399
|
+
|
|
400
|
+
def rpc_request(name, request, fields)
|
|
401
|
+
unless request.nil? || fields.empty?
|
|
402
|
+
raise ArgumentError, "#{name}: pass the request or its fields, not both (#{fields.keys.join(', ')})"
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
request || fields
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# The responses of a server-streaming call. Nothing is sent until
|
|
410
|
+
# it's read; then messages are yielded as they arrive, a few at most
|
|
411
|
+
# buffered ahead of the reader.
|
|
412
|
+
#
|
|
413
|
+
# items.list_items(category: "books").each { |item| ... }
|
|
414
|
+
# items.list_items(category: "books").first(10)
|
|
415
|
+
#
|
|
416
|
+
# Stopping early (`break`, `first(n)`, an exception) cancels the call.
|
|
417
|
+
# An error from the server raises Tonic::Rails::RpcError after the
|
|
418
|
+
# messages that came before it. A stream can be read once.
|
|
419
|
+
class ServerStream
|
|
420
|
+
include Enumerable
|
|
421
|
+
|
|
422
|
+
# @return [Hash, nil] response headers, once the stream has ended
|
|
423
|
+
attr_reader :headers
|
|
424
|
+
|
|
425
|
+
# @return [Hash, nil] trailing metadata, once the stream has ended
|
|
426
|
+
attr_reader :trailers
|
|
427
|
+
|
|
428
|
+
# @api private
|
|
429
|
+
def initialize(path, &read)
|
|
430
|
+
@path = path
|
|
431
|
+
@read = read
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# @yieldparam response [Google::Protobuf::AbstractMessage]
|
|
435
|
+
# @return [self, Enumerator] an Enumerator without a block
|
|
436
|
+
def each(&block)
|
|
437
|
+
return enum_for(:each) unless block
|
|
438
|
+
raise Error, "the #{@path} stream has already been read" unless @read
|
|
439
|
+
|
|
440
|
+
read = @read
|
|
441
|
+
@read = nil
|
|
442
|
+
read.call(self, block)
|
|
443
|
+
self
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# @api private
|
|
447
|
+
def finish(headers, trailers)
|
|
448
|
+
@headers = headers
|
|
449
|
+
@trailers = trailers
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def inspect
|
|
453
|
+
"#<#{self.class.name} #{@path}>"
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
require "tonic/rails/client/retry_policy"
|
|
@@ -24,18 +24,34 @@ module Tonic
|
|
|
24
24
|
module Instrumentation
|
|
25
25
|
EVENT = "process_rpc.tonic_rails"
|
|
26
26
|
|
|
27
|
+
# Outgoing calls made with Tonic::Rails::Client publish
|
|
28
|
+
# "request_rpc.tonic_rails", with the same payload plus :address, the
|
|
29
|
+
# server called, and :attempts, how many times it was tried.
|
|
30
|
+
REQUEST_EVENT = "request_rpc.tonic_rails"
|
|
31
|
+
|
|
27
32
|
module_function
|
|
28
33
|
|
|
29
34
|
# @api private
|
|
30
|
-
def instrument(path, metadata)
|
|
35
|
+
def instrument(path, metadata, &)
|
|
36
|
+
publish(EVENT, path, metadata, &)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @api private
|
|
40
|
+
def instrument_request(path, address, metadata, &)
|
|
41
|
+
publish(REQUEST_EVENT, path, metadata, address: address, &)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Yields the payload, or nil when nothing is listening.
|
|
45
|
+
# @api private
|
|
46
|
+
def publish(event, path, metadata, **extra)
|
|
31
47
|
# Without subscribers, skip building the payload: this runs on
|
|
32
48
|
# every RPC.
|
|
33
|
-
return yield unless ActiveSupport::Notifications.notifier.listening?(
|
|
49
|
+
return yield(nil) unless ActiveSupport::Notifications.notifier.listening?(event)
|
|
34
50
|
|
|
35
51
|
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(
|
|
38
|
-
result = yield
|
|
52
|
+
payload = { path: path, service: service, rpc: rpc, metadata: metadata, code: nil, status: nil, **extra }
|
|
53
|
+
ActiveSupport::Notifications.instrument(event, payload) do
|
|
54
|
+
result = yield(payload)
|
|
39
55
|
payload[:code] = 0
|
|
40
56
|
payload[:status] = :ok
|
|
41
57
|
result
|