tonic-rails 0.1.0 → 0.3.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -2
  3. data/Cargo.lock +5 -5
  4. data/README.md +204 -13
  5. data/ext/tonic_rails_native/Cargo.toml +2 -2
  6. data/ext/tonic_rails_native/src/body.rs +113 -49
  7. data/ext/tonic_rails_native/src/dispatch.rs +67 -8
  8. data/ext/tonic_rails_native/src/framing.rs +50 -0
  9. data/ext/tonic_rails_native/src/gvl.rs +109 -21
  10. data/ext/tonic_rails_native/src/lib.rs +6 -1
  11. data/ext/tonic_rails_native/src/log.rs +131 -0
  12. data/ext/tonic_rails_native/src/net.rs +7 -1
  13. data/ext/tonic_rails_native/src/server.rs +67 -6
  14. data/lib/generators/tonic_rails/install/install_generator.rb +45 -0
  15. data/lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt +21 -0
  16. data/lib/generators/tonic_rails/service/service_generator.rb +110 -0
  17. data/lib/generators/tonic_rails/service/templates/handler.rb.tt +38 -0
  18. data/lib/generators/tonic_rails/service/templates/service.proto.tt +16 -0
  19. data/lib/generators/tonic_rails/service/templates/service_spec.rb.tt +17 -0
  20. data/lib/generators/tonic_rails/service/templates/service_test.rb.tt +14 -0
  21. data/lib/puma/plugin/tonic_rails.rb +116 -0
  22. data/lib/tonic/rails/base.rb +169 -23
  23. data/lib/tonic/rails/configuration.rb +27 -4
  24. data/lib/tonic/rails/health.rb +105 -0
  25. data/lib/tonic/rails/instrumentation.rb +78 -0
  26. data/lib/tonic/rails/metadata.rb +50 -0
  27. data/lib/tonic/rails/peer.rb +60 -0
  28. data/lib/tonic/rails/railtie.rb +2 -2
  29. data/lib/tonic/rails/router.rb +1 -0
  30. data/lib/tonic/rails/rpc_error.rb +73 -6
  31. data/lib/tonic/rails/server.rb +82 -12
  32. data/lib/tonic/rails/tasks/tonic-rails.rake +13 -1
  33. data/lib/tonic/rails/testing/errors.rb +47 -0
  34. data/lib/tonic/rails/testing/minitest.rb +44 -0
  35. data/lib/tonic/rails/testing/rspec.rb +53 -0
  36. data/lib/tonic/rails/testing.rb +35 -4
  37. data/lib/tonic/rails/version.rb +1 -1
  38. data/lib/tonic-rails.rb +4 -0
  39. metadata +17 -1
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module TonicRails
6
+ module Generators
7
+ # rails generate tonic_rails:service Item GetItem ListItems:server ImportItems:client Sync:bidi
8
+ #
9
+ # Creates a .proto with the service and its messages, a handler with a
10
+ # method per RPC, the handler's registration, and a test.
11
+ class ServiceGenerator < ::Rails::Generators::NamedBase
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ STREAM_KINDS = %w[unary server client bidi].freeze
15
+
16
+ argument :rpcs, type: :array, default: [], banner: "Rpc[:server|:client|:bidi] ..."
17
+
18
+ class_option :package, type: :string,
19
+ desc: "Protobuf package (default: <app name>.v1)"
20
+
21
+ def check_rpcs
22
+ rpc_specs.each do |rpc|
23
+ next if rpc[:name].match?(/\A[A-Z][A-Za-z0-9]*\z/) && STREAM_KINDS.include?(rpc[:kind])
24
+
25
+ raise Thor::Error, "Invalid RPC #{rpc[:raw].inspect}: use CamelCase names, optionally :server, :client or :bidi"
26
+ end
27
+ end
28
+
29
+ def create_proto
30
+ template "service.proto.tt", "app/protos/#{package_path}/#{service_file}_service.proto"
31
+ end
32
+
33
+ def create_handler
34
+ template "handler.rb.tt", "app/grpc/#{service_file}_service.rb"
35
+ end
36
+
37
+ def register_handler
38
+ initializer = "config/initializers/tonic_rails.rb"
39
+ invoke "tonic_rails:install", [], skip_next_steps: true unless File.exist?(File.join(destination_root, initializer))
40
+ inject_into_file initializer, " Tonic::Rails.server.register(#{service_class})\n",
41
+ after: "Rails.application.config.to_prepare do\n"
42
+ end
43
+
44
+ def create_test
45
+ if File.directory?(File.join(destination_root, "spec"))
46
+ template "service_spec.rb.tt", "spec/grpc/#{service_file}_service_spec.rb"
47
+ else
48
+ template "service_test.rb.tt", "test/grpc/#{service_file}_service_test.rb"
49
+ end
50
+ end
51
+
52
+ def show_next_steps
53
+ say "\nNext: bin/rails tonic_rails:compile_protos, then fill in app/grpc/#{service_file}_service.rb", :green
54
+ end
55
+
56
+ private
57
+
58
+ # "Item" and "ItemService" both mean the service ItemService.
59
+ def service_base
60
+ class_name.delete_suffix("Service")
61
+ end
62
+
63
+ def service_file
64
+ service_base.underscore
65
+ end
66
+
67
+ def service_class
68
+ "#{service_base}Service"
69
+ end
70
+
71
+ def package
72
+ options[:package] || "#{app_name}.v1"
73
+ end
74
+
75
+ def app_name
76
+ app = defined?(::Rails.application) && ::Rails.application
77
+ name = app ? app.class.module_parent_name : File.basename(destination_root)
78
+ name.to_s.underscore.tr("-", "_").presence || "app"
79
+ end
80
+
81
+ def package_path
82
+ package.tr(".", "/")
83
+ end
84
+
85
+ def ruby_module
86
+ package.split(".").map(&:camelize).join("::")
87
+ end
88
+
89
+ def full_service_name
90
+ "#{package}.#{service_class}"
91
+ end
92
+
93
+ def rpc_specs
94
+ list = rpcs.empty? ? ["Get#{service_base}"] : rpcs
95
+ list.map do |raw|
96
+ name, kind = raw.split(":", 2)
97
+ { raw: raw, name: name.to_s, kind: kind || "unary", method: name.to_s.underscore }
98
+ end
99
+ end
100
+
101
+ def proto_rpc_line(rpc)
102
+ request = "#{rpc[:name]}Request"
103
+ response = "#{rpc[:name]}Response"
104
+ request = "stream #{request}" if %w[client bidi].include?(rpc[:kind])
105
+ response = "stream #{response}" if %w[server bidi].include?(rpc[:kind])
106
+ "rpc #{rpc[:name]}(#{request}) returns (#{response});"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "<%= package_path %>/<%= service_file %>_service_pb"
4
+
5
+ class <%= service_class %>
6
+ include Tonic::Rails::Base
7
+
8
+ rpc_service "<%= full_service_name %>"
9
+ request_format :message
10
+ <% rpc_specs.each do |rpc| %>
11
+ <% case rpc[:kind] -%>
12
+ <% when "unary" -%>
13
+ # @param request [<%= ruby_module %>::<%= rpc[:name] %>Request]
14
+ # @return [<%= ruby_module %>::<%= rpc[:name] %>Response]
15
+ def <%= rpc[:method] %>(request)
16
+ raise Tonic::Rails::RpcError.new("<%= rpc[:name] %> is not implemented yet", code: :unimplemented)
17
+ end
18
+ <% when "server" -%>
19
+ # @param request [<%= ruby_module %>::<%= rpc[:name] %>Request]
20
+ # @return [Enumerable<<%= ruby_module %>::<%= rpc[:name] %>Response>] each element is streamed as it's produced
21
+ def <%= rpc[:method] %>(request)
22
+ raise Tonic::Rails::RpcError.new("<%= rpc[:name] %> is not implemented yet", code: :unimplemented)
23
+ end
24
+ <% when "client" -%>
25
+ # @param requests [Enumerator<<%= ruby_module %>::<%= rpc[:name] %>Request>] yields messages as they arrive
26
+ # @return [<%= ruby_module %>::<%= rpc[:name] %>Response]
27
+ def <%= rpc[:method] %>(requests)
28
+ raise Tonic::Rails::RpcError.new("<%= rpc[:name] %> is not implemented yet", code: :unimplemented)
29
+ end
30
+ <% else -%>
31
+ # @param requests [Enumerator<<%= ruby_module %>::<%= rpc[:name] %>Request>] yields messages as they arrive
32
+ # @return [Enumerable<<%= ruby_module %>::<%= rpc[:name] %>Response>]
33
+ def <%= rpc[:method] %>(requests)
34
+ raise Tonic::Rails::RpcError.new("<%= rpc[:name] %> is not implemented yet", code: :unimplemented)
35
+ end
36
+ <% end -%>
37
+ <% end -%>
38
+ end
@@ -0,0 +1,16 @@
1
+ syntax = "proto3";
2
+
3
+ package <%= package %>;
4
+
5
+ service <%= service_class %> {
6
+ <% rpc_specs.each do |rpc| -%>
7
+ <%= proto_rpc_line(rpc) %>
8
+ <% end -%>
9
+ }
10
+ <% rpc_specs.each do |rpc| %>
11
+ message <%= rpc[:name] %>Request {
12
+ }
13
+
14
+ message <%= rpc[:name] %>Response {
15
+ }
16
+ <% end -%>
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+ require "tonic/rails/testing/rspec"
5
+
6
+ RSpec.describe <%= service_class %> do
7
+ <% rpc_specs.each_with_index do |rpc, i| -%>
8
+ <% if i.positive? %>
9
+ <% end -%>
10
+ describe "<%= rpc[:name] %>" do
11
+ it "is not implemented yet" do
12
+ request = <%= %w[client bidi].include?(rpc[:kind]) ? "[#{ruby_module}::#{rpc[:name]}Request.new]" : "#{ruby_module}::#{rpc[:name]}Request.new" %>
13
+ expect { Tonic::Rails::Testing.call(described_class, :<%= rpc[:name] %>, request) }.to raise_grpc_error(:unimplemented)
14
+ end
15
+ end
16
+ <% end -%>
17
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "tonic/rails/testing/minitest"
5
+
6
+ class <%= service_class %>Test < ActiveSupport::TestCase
7
+ include Tonic::Rails::Testing::Assertions
8
+ <% rpc_specs.each do |rpc| %>
9
+ test "<%= rpc[:name] %> is not implemented yet" do
10
+ request = <%= %w[client bidi].include?(rpc[:kind]) ? "[#{ruby_module}::#{rpc[:name]}Request.new]" : "#{ruby_module}::#{rpc[:name]}Request.new" %>
11
+ assert_grpc_error(:unimplemented) { Tonic::Rails::Testing.call(<%= service_class %>, :<%= rpc[:name] %>, request) }
12
+ end
13
+ <% end -%>
14
+ end
@@ -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
@@ -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 = if spec[:request_stream]
149
- Enumerator.new do |y|
150
- while (message = read_request(reader, spec))
151
- y << message
152
- end
153
- end
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
- read_request(reader, spec) || raise(RpcError.new("missing request message", code: :invalid_argument))
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
- result = public_send(spec[:handler], request)
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
- 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}"
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
- private
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 every request, not just errors, to stderr. Default false.
50
+ # Log one line per RPC (path, status, duration) through `logger`.
51
+ # Unexpected exceptions are always logged. Default false.
51
52
  attr_accessor :log_requests
52
53
 
53
54
  # Callables wrapping every RPC, outermost first:
@@ -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
- unless interceptors.respond_to?(:each) && interceptors.all? { |i| i.respond_to?(:call) }
116
- raise ArgumentError, "interceptors must be an Array of callables"
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?