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.
@@ -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
- struct ThreadWaker(std::thread::Thread);
105
- impl Wake for ThreadWaker {
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.0.unpark();
116
+ self.wake_by_ref();
108
117
  }
109
118
  fn wake_by_ref(self: &Arc<Self>) {
110
- self.0.unpark();
119
+ *self.woken.lock() = true;
120
+ self.condvar.notify_one();
111
121
  }
112
122
  }
113
123
 
114
- let waker = Waker::from(Arc::new(ThreadWaker(std::thread::current())));
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
- std::thread::park();
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
  }
@@ -248,7 +248,10 @@ module Tonic
248
248
 
249
249
  @request = build_request(reader, spec)
250
250
  response = nil
251
- run_callbacks(:rpc) { response = respond(public_send(spec[:handler], @request), spec, sender) }
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?(EVENT)
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(EVENT, payload) do
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