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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -2
- data/Cargo.lock +5 -5
- data/README.md +204 -13
- 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 +67 -8
- data/ext/tonic_rails_native/src/framing.rs +50 -0
- data/ext/tonic_rails_native/src/gvl.rs +109 -21
- data/ext/tonic_rails_native/src/lib.rs +6 -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 +67 -6
- data/lib/generators/tonic_rails/install/install_generator.rb +45 -0
- data/lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt +21 -0
- data/lib/generators/tonic_rails/service/service_generator.rb +110 -0
- data/lib/generators/tonic_rails/service/templates/handler.rb.tt +38 -0
- data/lib/generators/tonic_rails/service/templates/service.proto.tt +16 -0
- data/lib/generators/tonic_rails/service/templates/service_spec.rb.tt +17 -0
- data/lib/generators/tonic_rails/service/templates/service_test.rb.tt +14 -0
- data/lib/puma/plugin/tonic_rails.rb +116 -0
- data/lib/tonic/rails/base.rb +169 -23
- data/lib/tonic/rails/configuration.rb +27 -4
- data/lib/tonic/rails/health.rb +105 -0
- data/lib/tonic/rails/instrumentation.rb +78 -0
- data/lib/tonic/rails/metadata.rb +50 -0
- data/lib/tonic/rails/peer.rb +60 -0
- data/lib/tonic/rails/railtie.rb +2 -2
- data/lib/tonic/rails/router.rb +1 -0
- data/lib/tonic/rails/rpc_error.rb +73 -6
- data/lib/tonic/rails/server.rb +82 -12
- data/lib/tonic/rails/tasks/tonic-rails.rake +13 -1
- data/lib/tonic/rails/testing/errors.rb +47 -0
- data/lib/tonic/rails/testing/minitest.rb +44 -0
- data/lib/tonic/rails/testing/rspec.rb +53 -0
- data/lib/tonic/rails/testing.rb +35 -4
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +4 -0
- metadata +17 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tonic
|
|
4
|
+
module Rails
|
|
5
|
+
# What grpc.health.v1 reports, per service ("" = the whole server).
|
|
6
|
+
#
|
|
7
|
+
# A service reports SERVING only while the app hasn't marked it
|
|
8
|
+
# NOT_SERVING (Server#not_serving!) *and* the optional
|
|
9
|
+
# `config.health_check` passes. Neither overrides the other.
|
|
10
|
+
# @api private
|
|
11
|
+
class Health
|
|
12
|
+
def initialize(configuration, router)
|
|
13
|
+
@configuration = configuration
|
|
14
|
+
@router = router
|
|
15
|
+
@marks = {}
|
|
16
|
+
@check_passing = true
|
|
17
|
+
@native = nil
|
|
18
|
+
@checker = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def mark(services, serving)
|
|
22
|
+
names = services.empty? ? names_to_report : services.map(&:to_s)
|
|
23
|
+
names.each { |name| @marks[name] = serving }
|
|
24
|
+
publish(names)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Start reporting through a running native server.
|
|
28
|
+
def attach(native)
|
|
29
|
+
@native = native
|
|
30
|
+
@check_passing = true
|
|
31
|
+
publish(names_to_report)
|
|
32
|
+
start_checker
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Stop the checker before shutdown flips everything NOT_SERVING, so
|
|
36
|
+
# a passing check can't flip it back.
|
|
37
|
+
def detach
|
|
38
|
+
stop_checker
|
|
39
|
+
@native = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def names_to_report
|
|
45
|
+
["", *@router.service_names, *@marks.keys].uniq
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def publish(names)
|
|
49
|
+
native = @native
|
|
50
|
+
return unless native
|
|
51
|
+
|
|
52
|
+
names.each { |name| native.set_health(name, @check_passing && @marks.fetch(name, true)) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def start_checker
|
|
56
|
+
check = @configuration.health_check
|
|
57
|
+
return unless check
|
|
58
|
+
|
|
59
|
+
@wakeup = Thread::Queue.new
|
|
60
|
+
@checker = Thread.new do
|
|
61
|
+
Thread.current.name = "tonic-rails-health"
|
|
62
|
+
loop do
|
|
63
|
+
passing = run_check(check)
|
|
64
|
+
changed(passing) if passing != @check_passing
|
|
65
|
+
break if @wakeup.pop(timeout: @configuration.health_check_interval)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def stop_checker
|
|
71
|
+
return unless @checker
|
|
72
|
+
|
|
73
|
+
@wakeup << :stop
|
|
74
|
+
@checker.join(@configuration.drain_timeout.to_f + 5)
|
|
75
|
+
@checker = nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def changed(passing)
|
|
79
|
+
@check_passing = passing
|
|
80
|
+
publish(names_to_report)
|
|
81
|
+
logger = @configuration.logger
|
|
82
|
+
return unless logger
|
|
83
|
+
|
|
84
|
+
if passing
|
|
85
|
+
logger.info("tonic-rails: health check passing; SERVING")
|
|
86
|
+
else
|
|
87
|
+
logger.warn("tonic-rails: health check failing; NOT_SERVING")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Runs inside the Rails executor so database connections are returned.
|
|
92
|
+
def run_check(check)
|
|
93
|
+
result = if defined?(::Rails.application) && ::Rails.application
|
|
94
|
+
::Rails.application.executor.wrap { check.call }
|
|
95
|
+
else
|
|
96
|
+
check.call
|
|
97
|
+
end
|
|
98
|
+
result ? true : false
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
Tonic::Rails.log_exception(e, "health check")
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/notifications"
|
|
5
|
+
|
|
6
|
+
module Tonic
|
|
7
|
+
module Rails
|
|
8
|
+
# Every RPC is wrapped in an ActiveSupport::Notifications event, so APM
|
|
9
|
+
# agents, OpenTelemetry and your own subscribers can observe it:
|
|
10
|
+
#
|
|
11
|
+
# ActiveSupport::Notifications.subscribe("process_rpc.tonic_rails") do |event|
|
|
12
|
+
# event.payload[:path] # "/demo.v1.ItemService/GetItem"
|
|
13
|
+
# event.payload[:service] # "demo.v1.ItemService"
|
|
14
|
+
# event.payload[:rpc] # "GetItem"
|
|
15
|
+
# event.payload[:metadata] # request metadata Hash
|
|
16
|
+
# event.payload[:code] # gRPC status code, 0 = OK
|
|
17
|
+
# event.payload[:status] # :ok, :not_found, :internal, ...
|
|
18
|
+
# event.duration # milliseconds
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# When a call fails, :exception_object holds the RpcError. If a handler
|
|
22
|
+
# raised something unexpected, that RpcError's #cause is the original
|
|
23
|
+
# exception.
|
|
24
|
+
module Instrumentation
|
|
25
|
+
EVENT = "process_rpc.tonic_rails"
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# @api private
|
|
30
|
+
def instrument(path, metadata)
|
|
31
|
+
# Without subscribers, skip building the payload: this runs on
|
|
32
|
+
# every RPC.
|
|
33
|
+
return yield unless ActiveSupport::Notifications.notifier.listening?(EVENT)
|
|
34
|
+
|
|
35
|
+
service, rpc = path.delete_prefix("/").split("/", 2)
|
|
36
|
+
payload = { path: path, service: service, rpc: rpc, metadata: metadata, code: nil, status: nil }
|
|
37
|
+
ActiveSupport::Notifications.instrument(EVENT, payload) do
|
|
38
|
+
result = yield
|
|
39
|
+
payload[:code] = 0
|
|
40
|
+
payload[:status] = :ok
|
|
41
|
+
result
|
|
42
|
+
rescue RpcError => e
|
|
43
|
+
payload[:code] = e.code
|
|
44
|
+
payload[:status] = e.code_name
|
|
45
|
+
raise
|
|
46
|
+
rescue StandardError
|
|
47
|
+
payload[:code] = RpcError::CODES[:internal]
|
|
48
|
+
payload[:status] = :internal
|
|
49
|
+
raise
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Logs one line per RPC through the configured logger, e.g.
|
|
54
|
+
# gRPC /demo.v1.ItemService/GetItem OK in 1.4ms
|
|
55
|
+
# gRPC /demo.v1.ItemService/GetItem NOT_FOUND in 0.9ms: item 7 not found
|
|
56
|
+
# Enabled by `config.log_requests = true`.
|
|
57
|
+
class RequestLogger
|
|
58
|
+
def initialize(logger)
|
|
59
|
+
@logger = logger
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def call(event)
|
|
63
|
+
payload = event.payload
|
|
64
|
+
status = (payload[:status] || :unknown).to_s.upcase
|
|
65
|
+
line = format("gRPC %<path>s %<status>s in %<ms>.1fms", path: payload[:path], status: status, ms: event.duration)
|
|
66
|
+
error = payload[:exception_object]
|
|
67
|
+
line = "#{line}: #{error.message}" if error && payload[:code] != 0
|
|
68
|
+
@logger.info(line)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Object] the subscription, for #unsubscribe
|
|
72
|
+
def subscribe
|
|
73
|
+
ActiveSupport::Notifications.subscribe(EVENT, self)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tonic
|
|
4
|
+
module Rails
|
|
5
|
+
# Converts handler-supplied gRPC metadata into header pairs.
|
|
6
|
+
#
|
|
7
|
+
# Names are lower-cased and must be valid gRPC metadata names; names
|
|
8
|
+
# ending in "-bin" carry binary values, which are base64-encoded as the
|
|
9
|
+
# gRPC spec requires. Other values must be printable ASCII.
|
|
10
|
+
module Metadata
|
|
11
|
+
NAME = /\A[0-9a-z_.-]+\z/
|
|
12
|
+
PRINTABLE = /\A[\x20-\x7e]*\z/
|
|
13
|
+
RESERVED = %w[content-type te user-agent content-length connection transfer-encoding].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param metadata [Hash{String,Symbol=>String,Array<String>}]
|
|
18
|
+
# @return [Hash{String=>String}]
|
|
19
|
+
# @raise [ArgumentError] for invalid or reserved names and values
|
|
20
|
+
def encode(metadata)
|
|
21
|
+
metadata.each_with_object({}) do |(name, value), out|
|
|
22
|
+
name = name.to_s.downcase
|
|
23
|
+
validate_name!(name)
|
|
24
|
+
out[name] = encode_value(name, value)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate_name!(name)
|
|
29
|
+
raise ArgumentError, "invalid gRPC metadata name #{name.inspect}" unless NAME.match?(name)
|
|
30
|
+
return unless RESERVED.include?(name) || (name.start_with?("grpc-") && name != "grpc-status-details-bin")
|
|
31
|
+
|
|
32
|
+
raise ArgumentError, "gRPC metadata name #{name.inspect} is reserved"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def encode_value(name, value)
|
|
36
|
+
values = Array(value).map(&:to_s)
|
|
37
|
+
if name.end_with?("-bin")
|
|
38
|
+
values.map { |v| [v].pack("m0") }.join(",")
|
|
39
|
+
else
|
|
40
|
+
values.each do |v|
|
|
41
|
+
next if PRINTABLE.match?(v)
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, "gRPC metadata #{name} must be printable ASCII; use a -bin name for binary"
|
|
44
|
+
end
|
|
45
|
+
values.join(",")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tonic
|
|
4
|
+
module Rails
|
|
5
|
+
# The client on the other end of an RPC, available in handlers as
|
|
6
|
+
# `peer`:
|
|
7
|
+
#
|
|
8
|
+
# peer.address # => "10.0.4.17:51234"
|
|
9
|
+
# peer.ip # => "10.0.4.17"
|
|
10
|
+
# peer.certificate # => OpenSSL::X509::Certificate, with mutual TLS
|
|
11
|
+
# peer.certificate&.subject.to_s # => "/CN=billing-service"
|
|
12
|
+
class Peer
|
|
13
|
+
# @return [String, nil] "ip:port" ("[ipv6]:port" for IPv6)
|
|
14
|
+
attr_reader :address
|
|
15
|
+
|
|
16
|
+
# @param address [String, nil]
|
|
17
|
+
# @param certificates [Array<String>] DER-encoded chain, leaf first
|
|
18
|
+
def initialize(address = nil, certificates = [])
|
|
19
|
+
@address = address
|
|
20
|
+
@certificate_ders = certificates
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [String, nil]
|
|
24
|
+
def ip
|
|
25
|
+
return unless address
|
|
26
|
+
|
|
27
|
+
host = address.rpartition(":").first
|
|
28
|
+
host.delete_prefix("[").delete_suffix("]")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Integer, nil]
|
|
32
|
+
def port
|
|
33
|
+
address && Integer(address.rpartition(":").last, exception: false)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The client certificate presented over mutual TLS, or nil.
|
|
37
|
+
# @return [OpenSSL::X509::Certificate, nil]
|
|
38
|
+
def certificate
|
|
39
|
+
certificate_chain.first
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Array<OpenSSL::X509::Certificate>] leaf first
|
|
43
|
+
def certificate_chain
|
|
44
|
+
@certificate_chain ||= begin
|
|
45
|
+
require "openssl"
|
|
46
|
+
@certificate_ders.map { |der| OpenSSL::X509::Certificate.new(der) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# True when the client authenticated with a certificate.
|
|
51
|
+
def authenticated?
|
|
52
|
+
!@certificate_ders.empty?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def inspect
|
|
56
|
+
"#<#{self.class.name} #{address || 'unknown'}#{' (certificate)' if authenticated?}>"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
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
|
data/lib/tonic/rails/router.rb
CHANGED
|
@@ -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,9 +20,9 @@ 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
|
|
25
|
+
@health = Health.new(configuration, @router)
|
|
26
26
|
@lock = Mutex.new
|
|
27
27
|
end
|
|
28
28
|
|
|
@@ -51,25 +51,34 @@ module Tonic
|
|
|
51
51
|
@configuration.validate!
|
|
52
52
|
Tonic::Rails.native_extension!
|
|
53
53
|
Tonic::Rails._set_shutting_down(false)
|
|
54
|
-
|
|
54
|
+
# A Queue rather than a self-pipe: pushing to it is safe from trap
|
|
55
|
+
# handlers, and unlike pipes it can be waited on with a timeout
|
|
56
|
+
# on every platform (Windows select() only handles sockets).
|
|
57
|
+
@stop_signal = Thread::Queue.new
|
|
55
58
|
stop if @stop_requested # requested while we were starting up
|
|
56
59
|
@native = build_native_server
|
|
57
60
|
@native.start_server
|
|
61
|
+
log_pump = spawn_log_pump
|
|
62
|
+
@health.attach(@native)
|
|
58
63
|
@state = :running
|
|
59
64
|
|
|
65
|
+
request_log = subscribe_request_logger
|
|
60
66
|
previous_handlers = install_signal_handlers ? trap_signals : {}
|
|
61
67
|
workers = Array.new(@configuration.dispatch_threads) { |i| spawn_worker(i) }
|
|
62
68
|
log_startup
|
|
63
69
|
yield self if block_given?
|
|
64
70
|
|
|
65
71
|
wait_for_stop(workers)
|
|
72
|
+
@health.detach
|
|
66
73
|
shutdown(workers)
|
|
67
74
|
ensure
|
|
75
|
+
ActiveSupport::Notifications.unsubscribe(request_log) if request_log
|
|
68
76
|
previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
|
|
77
|
+
@health.detach
|
|
69
78
|
@native&.stop_server
|
|
79
|
+
stop_log_pump(log_pump)
|
|
70
80
|
@native = nil
|
|
71
|
-
|
|
72
|
-
@stop_reader = @stop_writer = nil
|
|
81
|
+
@stop_signal = nil
|
|
73
82
|
Tonic::Rails._set_shutting_down(false)
|
|
74
83
|
@state = :stopped
|
|
75
84
|
end
|
|
@@ -79,15 +88,32 @@ module Tonic
|
|
|
79
88
|
# other threads; returns immediately.
|
|
80
89
|
def stop
|
|
81
90
|
@stop_requested = true
|
|
82
|
-
@
|
|
83
|
-
rescue IOError
|
|
84
|
-
# Already stopped.
|
|
91
|
+
@stop_signal&.push(:stop)
|
|
85
92
|
end
|
|
86
93
|
|
|
87
94
|
def running?
|
|
88
95
|
@state == :running
|
|
89
96
|
end
|
|
90
97
|
|
|
98
|
+
# Mark the whole server, or the given services (fully-qualified
|
|
99
|
+
# names), SERVING in grpc.health.v1. Takes effect immediately when
|
|
100
|
+
# running, otherwise when the server starts.
|
|
101
|
+
#
|
|
102
|
+
# With `config.health_check`, a service reports SERVING only while the
|
|
103
|
+
# check passes *and* it's marked serving here.
|
|
104
|
+
def serving!(*services)
|
|
105
|
+
@health.mark(services, true)
|
|
106
|
+
self
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Mark the whole server, or the given services, NOT_SERVING (e.g. for
|
|
110
|
+
# maintenance), so load balancers and readiness probes route around
|
|
111
|
+
# this instance. Calls are still served.
|
|
112
|
+
def not_serving!(*services)
|
|
113
|
+
@health.mark(services, false)
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
91
117
|
# @return [Hash{Symbol=>Integer}] request and connection counters
|
|
92
118
|
def stats
|
|
93
119
|
native = @native
|
|
@@ -113,7 +139,6 @@ module Tonic
|
|
|
113
139
|
native.set_worker_threads(config.worker_threads || 0)
|
|
114
140
|
native.set_keepalive(config.keepalive_interval.to_f, config.keepalive_timeout.to_f)
|
|
115
141
|
native.set_dispatch_queue_size(config.dispatch_queue_size)
|
|
116
|
-
native.set_log_requests(config.log_requests ? true : false)
|
|
117
142
|
native.set_compression(config.compression ? true : false)
|
|
118
143
|
native.set_reflection(config.reflection ? true : false)
|
|
119
144
|
native.set_reflection_descriptors(Reflection.file_descriptors(@router.service_names)) if config.reflection
|
|
@@ -133,8 +158,9 @@ module Tonic
|
|
|
133
158
|
end
|
|
134
159
|
|
|
135
160
|
guarded = lambda do |path, metadata, transport|
|
|
136
|
-
call.call(path, metadata, transport)
|
|
137
|
-
rescue RpcError
|
|
161
|
+
Instrumentation.instrument(path, metadata) { call.call(path, metadata, transport) }
|
|
162
|
+
rescue RpcError => e
|
|
163
|
+
attach_error_trailers(transport, e)
|
|
138
164
|
raise
|
|
139
165
|
rescue StandardError => e
|
|
140
166
|
Tonic::Rails.log_exception(e, path)
|
|
@@ -144,6 +170,15 @@ module Tonic
|
|
|
144
170
|
wrap_with_rails_executor(guarded)
|
|
145
171
|
end
|
|
146
172
|
|
|
173
|
+
# An RpcError's own metadata and rich details travel as trailers.
|
|
174
|
+
def attach_error_trailers(transport, error)
|
|
175
|
+
return if error.metadata.empty? && error.details.empty?
|
|
176
|
+
|
|
177
|
+
transport[:sender]&.add_trailers(error.trailers)
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
Tonic::Rails.log_exception(e, "RpcError trailers")
|
|
180
|
+
end
|
|
181
|
+
|
|
147
182
|
# Each RPC runs inside the Rails executor (or reloader, when code
|
|
148
183
|
# reloading is on): ActiveRecord connections are returned, query
|
|
149
184
|
# caches and CurrentAttributes are reset — the same isolation Rack
|
|
@@ -156,6 +191,41 @@ module Tonic
|
|
|
156
191
|
->(path, metadata, transport) { wrapper.wrap { callback.call(path, metadata, transport) } }
|
|
157
192
|
end
|
|
158
193
|
|
|
194
|
+
def subscribe_request_logger
|
|
195
|
+
return unless @configuration.log_requests && @configuration.logger
|
|
196
|
+
|
|
197
|
+
Instrumentation::RequestLogger.new(@configuration.logger).subscribe
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Transport events from the Rust side (rejected calls, expired
|
|
201
|
+
# deadlines, refused connections, faults) can't call Ruby directly;
|
|
202
|
+
# this thread drains them into the configured logger.
|
|
203
|
+
def spawn_log_pump
|
|
204
|
+
native = @native
|
|
205
|
+
logger = @configuration.logger
|
|
206
|
+
@log_pump_running = true
|
|
207
|
+
Thread.new do
|
|
208
|
+
Thread.current.name = "tonic-rails-log"
|
|
209
|
+
write_transport_logs(logger, native.next_log_events(0.25)) while @log_pump_running
|
|
210
|
+
write_transport_logs(logger, native.next_log_events(0)) # whatever shutdown produced
|
|
211
|
+
rescue StandardError => e
|
|
212
|
+
warn("tonic-rails: log thread stopped: #{e.class}: #{e.message}")
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def stop_log_pump(thread)
|
|
217
|
+
return unless thread
|
|
218
|
+
|
|
219
|
+
@log_pump_running = false
|
|
220
|
+
thread.join(2)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def write_transport_logs(logger, events)
|
|
224
|
+
return unless logger
|
|
225
|
+
|
|
226
|
+
events.each { |level, message| logger.public_send(level, "tonic-rails: #{message}") }
|
|
227
|
+
end
|
|
228
|
+
|
|
159
229
|
def spawn_worker(index)
|
|
160
230
|
native = @native
|
|
161
231
|
Thread.new do
|
|
@@ -174,7 +244,7 @@ module Tonic
|
|
|
174
244
|
|
|
175
245
|
def wait_for_stop(workers)
|
|
176
246
|
loop do
|
|
177
|
-
break if @
|
|
247
|
+
break if @stop_signal.pop(timeout: 1)
|
|
178
248
|
|
|
179
249
|
dead = workers.reject(&:alive?)
|
|
180
250
|
unless dead.empty?
|
|
@@ -27,7 +27,19 @@ namespace :tonic_rails do
|
|
|
27
27
|
ruby_out = ENV.fetch("RUBY_OUT", "lib/grpc")
|
|
28
28
|
protos = Dir.glob(File.join(proto_dir, "**", "*.proto"))
|
|
29
29
|
abort "No .proto files found in #{proto_dir}/" if protos.empty?
|
|
30
|
-
|
|
30
|
+
version = begin
|
|
31
|
+
IO.popen(%w[protoc --version], err: File::NULL, &:read)[/\d+(\.\d+)+/]
|
|
32
|
+
rescue SystemCallError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
abort "protoc not found on PATH. Install the Protocol Buffers compiler (22.0 or newer)." unless version
|
|
36
|
+
# protoc before 22 emits the Ruby DSL that google-protobuf 4 removed
|
|
37
|
+
# (`undefined method 'build' for DescriptorPool` at load time).
|
|
38
|
+
major = version.split(".").then { |parts| parts.first == "3" ? parts[1].to_i : parts.first.to_i }
|
|
39
|
+
if major < 22
|
|
40
|
+
abort "protoc #{version} is too old for google-protobuf 4: install protoc 22.0 or newer " \
|
|
41
|
+
"(distribution packages are often older; see https://github.com/protocolbuffers/protobuf/releases)."
|
|
42
|
+
end
|
|
31
43
|
|
|
32
44
|
FileUtils.mkdir_p(ruby_out)
|
|
33
45
|
sh "protoc", "--ruby_out=#{ruby_out}", "-I#{proto_dir}", *protos
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tonic
|
|
4
|
+
module Rails
|
|
5
|
+
module Testing
|
|
6
|
+
# Shared helpers for the RSpec matcher and Minitest assertions.
|
|
7
|
+
# @api private
|
|
8
|
+
module Errors
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Exceptions that carry a gRPC status: tonic-rails' own, and the
|
|
12
|
+
# official grpc gem client's, when it's loaded.
|
|
13
|
+
def classes
|
|
14
|
+
[Tonic::Rails::RpcError, (GRPC::BadStatus if defined?(GRPC::BadStatus))].compact
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def status?(error)
|
|
18
|
+
classes.any? { |klass| error.is_a?(klass) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Integer]
|
|
22
|
+
def code(error)
|
|
23
|
+
error.code
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [String] the status message the client sees. The grpc
|
|
27
|
+
# gem keeps it in #details (its #message adds the code).
|
|
28
|
+
def message(error)
|
|
29
|
+
text = defined?(GRPC::BadStatus) && error.is_a?(GRPC::BadStatus) ? error.details.to_s : error.message
|
|
30
|
+
text.dup.force_encoding(Encoding::UTF_8)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def code_name(code)
|
|
34
|
+
Tonic::Rails::RpcError::CODE_NAMES.fetch(code, code)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def message_matches?(expected, actual)
|
|
38
|
+
expected.is_a?(Regexp) ? expected.match?(actual) : expected.to_s == actual
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def describe(error)
|
|
42
|
+
"#{code_name(code(error)).to_s.upcase} (#{message(error).inspect})"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|