tonic-rails 0.1.0-aarch64-linux-gnu
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 +7 -0
- data/CHANGELOG.md +52 -0
- data/LICENSE +21 -0
- data/README.md +391 -0
- data/lib/tonic/rails/3.3/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/3.4/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/4.0/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/base.rb +215 -0
- data/lib/tonic/rails/configuration.rb +149 -0
- data/lib/tonic/rails/railtie.rb +56 -0
- data/lib/tonic/rails/reflection.rb +50 -0
- data/lib/tonic/rails/router.rb +71 -0
- data/lib/tonic/rails/rpc_error.rb +65 -0
- data/lib/tonic/rails/server.rb +224 -0
- data/lib/tonic/rails/tasks/tonic-rails.rake +36 -0
- data/lib/tonic/rails/testing.rb +85 -0
- data/lib/tonic/rails/version.rb +7 -0
- data/lib/tonic-rails.rb +91 -0
- metadata +107 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Tonic
|
|
6
|
+
module Rails
|
|
7
|
+
class Configuration
|
|
8
|
+
# Address the gRPC server binds to ("host:port"). Default "0.0.0.0:50051".
|
|
9
|
+
attr_accessor :bind_address
|
|
10
|
+
|
|
11
|
+
# Maximum request and response message size in bytes (also caps
|
|
12
|
+
# decompressed payloads). Default 4 MiB.
|
|
13
|
+
attr_accessor :max_message_size
|
|
14
|
+
|
|
15
|
+
# Start the gRPC server in a background thread when `rails server`
|
|
16
|
+
# boots. Prefer a dedicated process (`bin/rails tonic_rails:serve`)
|
|
17
|
+
# in production. Default false.
|
|
18
|
+
attr_accessor :auto_start
|
|
19
|
+
|
|
20
|
+
attr_accessor :logger
|
|
21
|
+
|
|
22
|
+
# Tokio worker threads for the transport. nil = number of CPUs.
|
|
23
|
+
attr_accessor :worker_threads
|
|
24
|
+
|
|
25
|
+
# Ruby threads running handlers. Match your ActiveRecord pool size
|
|
26
|
+
# (RAILS_MAX_THREADS). Default 4.
|
|
27
|
+
attr_accessor :dispatch_threads
|
|
28
|
+
|
|
29
|
+
# RPCs that may wait for a free dispatch thread. Beyond this the
|
|
30
|
+
# server answers RESOURCE_EXHAUSTED immediately. Default 128.
|
|
31
|
+
attr_accessor :dispatch_queue_size
|
|
32
|
+
|
|
33
|
+
# PEM certificate and private key. Set both to enable TLS.
|
|
34
|
+
attr_accessor :tls_cert_path, :tls_key_path
|
|
35
|
+
|
|
36
|
+
# PEM CA bundle. When set, clients must present a certificate
|
|
37
|
+
# signed by it (mutual TLS).
|
|
38
|
+
attr_accessor :tls_client_ca_path
|
|
39
|
+
|
|
40
|
+
# Seconds to wait for in-flight RPCs during shutdown. Default 10.
|
|
41
|
+
attr_accessor :drain_timeout
|
|
42
|
+
|
|
43
|
+
# Concurrent TCP connections; extra connections are refused.
|
|
44
|
+
# 0 = unlimited. Default 64.
|
|
45
|
+
attr_accessor :max_connections
|
|
46
|
+
|
|
47
|
+
# Concurrent HTTP/2 streams per connection. 0 = unlimited. Default 32.
|
|
48
|
+
attr_accessor :concurrency_limit_per_connection
|
|
49
|
+
|
|
50
|
+
# Log every request, not just errors, to stderr. Default false.
|
|
51
|
+
attr_accessor :log_requests
|
|
52
|
+
|
|
53
|
+
# Callables wrapping every RPC, outermost first:
|
|
54
|
+
#
|
|
55
|
+
# ->(path, metadata, next_call) { next_call.call }
|
|
56
|
+
#
|
|
57
|
+
# Raise RpcError to reject the call.
|
|
58
|
+
attr_accessor :interceptors
|
|
59
|
+
|
|
60
|
+
# gzip responses larger than 1 KiB for clients that accept it.
|
|
61
|
+
# Compressed requests are always accepted. Default false.
|
|
62
|
+
attr_accessor :compression
|
|
63
|
+
|
|
64
|
+
# Serve grpc.reflection.v1 (for grpcurl and friends). Exposes your
|
|
65
|
+
# API schema; keep it off in production. Default false.
|
|
66
|
+
attr_accessor :reflection
|
|
67
|
+
|
|
68
|
+
# HTTP/2 PING interval and ack timeout, in seconds. Defaults 15 / 5.
|
|
69
|
+
attr_accessor :keepalive_interval, :keepalive_timeout
|
|
70
|
+
|
|
71
|
+
# Look handler classes up again on each request so code reloading
|
|
72
|
+
# works. The Railtie enables this when Rails reloading is on.
|
|
73
|
+
attr_accessor :reload_handlers
|
|
74
|
+
|
|
75
|
+
def initialize
|
|
76
|
+
@bind_address = "0.0.0.0:50051"
|
|
77
|
+
@max_message_size = 4 * 1024 * 1024
|
|
78
|
+
@auto_start = false
|
|
79
|
+
@logger = Logger.new($stdout)
|
|
80
|
+
@worker_threads = nil
|
|
81
|
+
@dispatch_threads = 4
|
|
82
|
+
@dispatch_queue_size = 128
|
|
83
|
+
@tls_cert_path = nil
|
|
84
|
+
@tls_key_path = nil
|
|
85
|
+
@tls_client_ca_path = nil
|
|
86
|
+
@drain_timeout = 10
|
|
87
|
+
@max_connections = 64
|
|
88
|
+
@concurrency_limit_per_connection = 32
|
|
89
|
+
@log_requests = false
|
|
90
|
+
@interceptors = []
|
|
91
|
+
@compression = false
|
|
92
|
+
@reflection = false
|
|
93
|
+
@keepalive_interval = 15
|
|
94
|
+
@keepalive_timeout = 5
|
|
95
|
+
@reload_handlers = false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def tls?
|
|
99
|
+
!tls_cert_path.nil? && !tls_key_path.nil?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @raise [ArgumentError] describing the first invalid setting
|
|
103
|
+
def validate!
|
|
104
|
+
positive_integer!(:dispatch_threads)
|
|
105
|
+
positive_integer!(:dispatch_queue_size)
|
|
106
|
+
positive_integer!(:max_message_size)
|
|
107
|
+
non_negative_integer!(:max_connections)
|
|
108
|
+
non_negative_integer!(:concurrency_limit_per_connection)
|
|
109
|
+
positive_integer!(:worker_threads) unless worker_threads.nil?
|
|
110
|
+
%i[drain_timeout keepalive_interval keepalive_timeout].each { |name| non_negative_number!(name) }
|
|
111
|
+
|
|
112
|
+
raise ArgumentError, "max_message_size must be at most 4 GiB" if max_message_size > 0xFFFF_FFFF
|
|
113
|
+
raise ArgumentError, "tls_cert_path and tls_key_path must be set together" if tls_cert_path.nil? != tls_key_path.nil?
|
|
114
|
+
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
|
|
118
|
+
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def positive_integer!(name)
|
|
125
|
+
value = public_send(name)
|
|
126
|
+
return if value.is_a?(Integer) && value.positive?
|
|
127
|
+
|
|
128
|
+
raise ArgumentError,
|
|
129
|
+
"#{name} must be a positive Integer, got #{value.inspect}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def non_negative_integer!(name)
|
|
133
|
+
value = public_send(name)
|
|
134
|
+
return if value.is_a?(Integer) && !value.negative?
|
|
135
|
+
|
|
136
|
+
raise ArgumentError,
|
|
137
|
+
"#{name} must be a non-negative Integer, got #{value.inspect}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def non_negative_number!(name)
|
|
141
|
+
value = public_send(name)
|
|
142
|
+
return if value.is_a?(Numeric) && !value.negative?
|
|
143
|
+
|
|
144
|
+
raise ArgumentError,
|
|
145
|
+
"#{name} must be a non-negative number, got #{value.inspect}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tonic
|
|
4
|
+
module Rails
|
|
5
|
+
# Rails integration:
|
|
6
|
+
# - `config.tonic_rails.*` settings are copied onto Tonic::Rails.configuration
|
|
7
|
+
# (an initializer calling Tonic::Rails.configure can still override them)
|
|
8
|
+
# - the Rails logger is used by default
|
|
9
|
+
# - handler classes are looked up per request when code reloading is on
|
|
10
|
+
# - `auto_start` runs the server inside `rails server`
|
|
11
|
+
# - rake tasks: tonic_rails:serve, tonic_rails:routes, tonic_rails:compile_protos
|
|
12
|
+
class Railtie < ::Rails::Railtie
|
|
13
|
+
config.tonic_rails = ::ActiveSupport::OrderedOptions.new
|
|
14
|
+
|
|
15
|
+
initializer "tonic_rails.configuration" do |app|
|
|
16
|
+
configuration = Tonic::Rails.configuration
|
|
17
|
+
configuration.logger = ::Rails.logger if ::Rails.logger
|
|
18
|
+
configuration.reload_handlers = Railtie.reloading_enabled?(app)
|
|
19
|
+
|
|
20
|
+
app.config.tonic_rails.each do |key, value|
|
|
21
|
+
setter = "#{key}="
|
|
22
|
+
raise ArgumentError, "unknown tonic-rails setting config.tonic_rails.#{key}" unless configuration.respond_to?(setter)
|
|
23
|
+
|
|
24
|
+
configuration.public_send(setter, value)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
config.after_initialize do
|
|
29
|
+
configuration = Tonic::Rails.configuration
|
|
30
|
+
# Only inside an actual web server process — not consoles, rake
|
|
31
|
+
# tasks, or test runs. Forking servers (Puma cluster mode) must
|
|
32
|
+
# instead start the server per worker, e.g. in `on_worker_boot`.
|
|
33
|
+
if configuration.auto_start && defined?(::Rails::Server)
|
|
34
|
+
server = Tonic::Rails.server
|
|
35
|
+
thread = Thread.new do
|
|
36
|
+
Thread.current.name = "tonic-rails"
|
|
37
|
+
server.start(install_signal_handlers: false)
|
|
38
|
+
end
|
|
39
|
+
thread.report_on_exception = true
|
|
40
|
+
at_exit do
|
|
41
|
+
server.stop
|
|
42
|
+
thread.join(configuration.drain_timeout.to_f + 5)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.reloading_enabled?(app)
|
|
48
|
+
app.config.respond_to?(:enable_reloading) ? app.config.enable_reloading : !app.config.cache_classes
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
rake_tasks do
|
|
52
|
+
load File.expand_path("tasks/tonic-rails.rake", __dir__)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "google/protobuf/descriptor_pb"
|
|
4
|
+
|
|
5
|
+
module Tonic
|
|
6
|
+
module Rails
|
|
7
|
+
# Builds the protobuf file descriptors served by gRPC reflection from
|
|
8
|
+
# the descriptors already loaded in Ruby (the generated `_pb.rb` files).
|
|
9
|
+
module Reflection
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# @param service_names [Array<String>] fully-qualified service names
|
|
13
|
+
# @return [Array<String>] serialized google.protobuf.FileDescriptorProto,
|
|
14
|
+
# covering each service's file and every file its messages reference
|
|
15
|
+
def file_descriptors(service_names)
|
|
16
|
+
pool = Google::Protobuf::DescriptorPool.generated_pool
|
|
17
|
+
files = {}
|
|
18
|
+
seen = Set.new
|
|
19
|
+
pending = []
|
|
20
|
+
|
|
21
|
+
service_names.each do |name|
|
|
22
|
+
service = pool.lookup(name)
|
|
23
|
+
next unless service.is_a?(Google::Protobuf::ServiceDescriptor)
|
|
24
|
+
|
|
25
|
+
add_file(files, service.file_descriptor)
|
|
26
|
+
service.each { |method| pending << method.input_type << method.output_type }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
until pending.empty?
|
|
30
|
+
descriptor = pending.pop
|
|
31
|
+
next unless seen.add?(descriptor.name)
|
|
32
|
+
|
|
33
|
+
add_file(files, descriptor.file_descriptor)
|
|
34
|
+
next unless descriptor.is_a?(Google::Protobuf::Descriptor)
|
|
35
|
+
|
|
36
|
+
descriptor.each do |field|
|
|
37
|
+
subtype = field.subtype
|
|
38
|
+
pending << subtype if subtype
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
files.values.map { |fd| Google::Protobuf::FileDescriptorProto.encode(fd.to_proto) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_file(files, file_descriptor)
|
|
46
|
+
files[file_descriptor.name] ||= file_descriptor
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tonic
|
|
4
|
+
module Rails
|
|
5
|
+
# Maps gRPC method paths ("/package.Service/Method") to handler classes.
|
|
6
|
+
class Router
|
|
7
|
+
Route = Struct.new(:service_class, :rpc_name)
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@routes = {}
|
|
11
|
+
@services = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Register (or re-register, e.g. after a code reload) every RPC of
|
|
15
|
+
# a handler class.
|
|
16
|
+
# @param service_class [Class] a class including Tonic::Rails::Base
|
|
17
|
+
def add(service_class)
|
|
18
|
+
name = service_class.grpc_service_name
|
|
19
|
+
@routes.delete_if { |_, route| route.service_class.grpc_service_name == name }
|
|
20
|
+
@services[name] = service_class
|
|
21
|
+
service_class.grpc_rpcs.each_key do |rpc_name|
|
|
22
|
+
@routes["/#{name}/#{rpc_name}"] = Route.new(service_class, rpc_name)
|
|
23
|
+
end
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param path [String] gRPC method path
|
|
28
|
+
# @param metadata [Hash{String=>String}]
|
|
29
|
+
# @param call [Hash] :reader, :sender and optional :timeout_ms from the transport
|
|
30
|
+
# @return [String, nil] encoded unary response, or nil for streamed responses
|
|
31
|
+
def dispatch(path, metadata, call)
|
|
32
|
+
route = @routes[path]
|
|
33
|
+
raise RpcError.new("unknown method #{path}", code: :unimplemented) unless route
|
|
34
|
+
|
|
35
|
+
resolve(route.service_class).new.call_rpc(
|
|
36
|
+
route.rpc_name,
|
|
37
|
+
metadata: metadata,
|
|
38
|
+
reader: call[:reader],
|
|
39
|
+
sender: call[:sender],
|
|
40
|
+
timeout_ms: call[:timeout_ms],
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Array<String>] registered gRPC method paths
|
|
45
|
+
def paths
|
|
46
|
+
@routes.keys
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Array<String>] registered fully-qualified service names
|
|
50
|
+
def service_names
|
|
51
|
+
@services.keys
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def empty?
|
|
55
|
+
@routes.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# With code reloading on, look the class up again so edits to
|
|
61
|
+
# handler bodies take effect without a restart.
|
|
62
|
+
def resolve(service_class)
|
|
63
|
+
return service_class unless Tonic::Rails.configuration.reload_handlers
|
|
64
|
+
|
|
65
|
+
Object.const_get(service_class.name)
|
|
66
|
+
rescue NameError
|
|
67
|
+
service_class
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tonic
|
|
4
|
+
module Rails
|
|
5
|
+
# Raise from a handler to end the RPC with a gRPC status.
|
|
6
|
+
#
|
|
7
|
+
# raise Tonic::Rails::RpcError.new("product not found", code: :not_found)
|
|
8
|
+
#
|
|
9
|
+
# The message is sent to the client, so keep internal details out of
|
|
10
|
+
# it. Any other exception becomes INTERNAL with a generic message
|
|
11
|
+
# and is logged server-side.
|
|
12
|
+
class RpcError < Error
|
|
13
|
+
CODES = {
|
|
14
|
+
ok: 0,
|
|
15
|
+
cancelled: 1,
|
|
16
|
+
unknown: 2,
|
|
17
|
+
invalid_argument: 3,
|
|
18
|
+
deadline_exceeded: 4,
|
|
19
|
+
not_found: 5,
|
|
20
|
+
already_exists: 6,
|
|
21
|
+
permission_denied: 7,
|
|
22
|
+
resource_exhausted: 8,
|
|
23
|
+
failed_precondition: 9,
|
|
24
|
+
aborted: 10,
|
|
25
|
+
out_of_range: 11,
|
|
26
|
+
unimplemented: 12,
|
|
27
|
+
internal: 13,
|
|
28
|
+
unavailable: 14,
|
|
29
|
+
data_loss: 15,
|
|
30
|
+
unauthenticated: 16,
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
CODE_NAMES = CODES.invert.freeze
|
|
34
|
+
|
|
35
|
+
# @return [Integer] numeric gRPC status code
|
|
36
|
+
attr_reader :code
|
|
37
|
+
|
|
38
|
+
# @param message [String] status message sent to the client
|
|
39
|
+
# @param code [Symbol, Integer] gRPC status code (see CODES)
|
|
40
|
+
def initialize(message = nil, code: :unknown)
|
|
41
|
+
@code = self.class.normalize_code(code)
|
|
42
|
+
super(message || code_name.to_s)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Symbol] e.g. :not_found
|
|
46
|
+
def code_name
|
|
47
|
+
CODE_NAMES.fetch(@code)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @api private
|
|
51
|
+
def self.normalize_code(code)
|
|
52
|
+
case code
|
|
53
|
+
when Symbol, String
|
|
54
|
+
CODES.fetch(code.to_sym) { raise ArgumentError, "unknown gRPC status code: #{code.inspect}" }
|
|
55
|
+
when Integer
|
|
56
|
+
raise ArgumentError, "unknown gRPC status code: #{code}" unless CODE_NAMES.key?(code)
|
|
57
|
+
|
|
58
|
+
code
|
|
59
|
+
else
|
|
60
|
+
raise ArgumentError, "gRPC status code must be a Symbol or Integer, got #{code.class}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tonic
|
|
4
|
+
module Rails
|
|
5
|
+
# Runs the gRPC server: a tonic transport in Rust plus a pool of Ruby
|
|
6
|
+
# dispatch threads.
|
|
7
|
+
#
|
|
8
|
+
# server = Tonic::Rails::Server.new
|
|
9
|
+
# server.register(ProductService)
|
|
10
|
+
# server.start # blocks until SIGTERM / SIGINT or #stop
|
|
11
|
+
#
|
|
12
|
+
# Shutdown is graceful: health checks flip to NOT_SERVING, new
|
|
13
|
+
# connections are refused, in-flight RPCs get `drain_timeout` seconds
|
|
14
|
+
# to finish, then everything is torn down.
|
|
15
|
+
class Server
|
|
16
|
+
attr_reader :router, :configuration
|
|
17
|
+
|
|
18
|
+
def initialize(configuration: Tonic::Rails.configuration)
|
|
19
|
+
@configuration = configuration
|
|
20
|
+
@router = Router.new
|
|
21
|
+
@native = nil
|
|
22
|
+
@state = :stopped
|
|
23
|
+
@stop_reader = nil
|
|
24
|
+
@stop_writer = nil
|
|
25
|
+
@stop_requested = false
|
|
26
|
+
@lock = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param service_class [Class, String] handler class (or its name)
|
|
30
|
+
def register(service_class)
|
|
31
|
+
service_class = Object.const_get(service_class) if service_class.is_a?(String)
|
|
32
|
+
service_class.register_on(@router)
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Start serving and block until stopped.
|
|
37
|
+
# @param install_signal_handlers [Boolean] trap TERM and INT to stop.
|
|
38
|
+
# Pass false when embedding in a process that owns its signals.
|
|
39
|
+
# @yield once the server is accepting connections
|
|
40
|
+
def start(install_signal_handlers: true)
|
|
41
|
+
@lock.synchronize do
|
|
42
|
+
raise Error, "server is already running" unless @state == :stopped
|
|
43
|
+
|
|
44
|
+
@state = :starting
|
|
45
|
+
@stop_requested = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
raise Error, "no services registered" if @router.empty?
|
|
50
|
+
|
|
51
|
+
@configuration.validate!
|
|
52
|
+
Tonic::Rails.native_extension!
|
|
53
|
+
Tonic::Rails._set_shutting_down(false)
|
|
54
|
+
@stop_reader, @stop_writer = IO.pipe
|
|
55
|
+
stop if @stop_requested # requested while we were starting up
|
|
56
|
+
@native = build_native_server
|
|
57
|
+
@native.start_server
|
|
58
|
+
@state = :running
|
|
59
|
+
|
|
60
|
+
previous_handlers = install_signal_handlers ? trap_signals : {}
|
|
61
|
+
workers = Array.new(@configuration.dispatch_threads) { |i| spawn_worker(i) }
|
|
62
|
+
log_startup
|
|
63
|
+
yield self if block_given?
|
|
64
|
+
|
|
65
|
+
wait_for_stop(workers)
|
|
66
|
+
shutdown(workers)
|
|
67
|
+
ensure
|
|
68
|
+
previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
|
|
69
|
+
@native&.stop_server
|
|
70
|
+
@native = nil
|
|
71
|
+
[@stop_reader, @stop_writer].each { |io| io.close if io && !io.closed? }
|
|
72
|
+
@stop_reader = @stop_writer = nil
|
|
73
|
+
Tonic::Rails._set_shutting_down(false)
|
|
74
|
+
@state = :stopped
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Request a graceful stop. Safe to call from signal handlers and
|
|
79
|
+
# other threads; returns immediately.
|
|
80
|
+
def stop
|
|
81
|
+
@stop_requested = true
|
|
82
|
+
@stop_writer&.write_nonblock(".", exception: false)
|
|
83
|
+
rescue IOError
|
|
84
|
+
# Already stopped.
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def running?
|
|
88
|
+
@state == :running
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Hash{Symbol=>Integer}] request and connection counters
|
|
92
|
+
def stats
|
|
93
|
+
native = @native
|
|
94
|
+
return native.stats if native
|
|
95
|
+
|
|
96
|
+
{ total: 0, successful: 0, errors: 0, cancelled: 0, deadline_exceeded: 0,
|
|
97
|
+
resource_exhausted: 0, unimplemented: 0, active_connections: 0, queued: 0 }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def build_native_server
|
|
103
|
+
config = @configuration
|
|
104
|
+
native = TonicRails::Native::Server.new(config.bind_address, config.max_message_size)
|
|
105
|
+
native.on_request(build_callback)
|
|
106
|
+
native.set_routes(@router.paths)
|
|
107
|
+
if config.tls?
|
|
108
|
+
native.set_tls(config.tls_cert_path, config.tls_key_path)
|
|
109
|
+
native.set_tls_client_ca(config.tls_client_ca_path) if config.tls_client_ca_path
|
|
110
|
+
end
|
|
111
|
+
native.set_max_connections(config.max_connections)
|
|
112
|
+
native.set_concurrency_limit_per_connection(config.concurrency_limit_per_connection)
|
|
113
|
+
native.set_worker_threads(config.worker_threads || 0)
|
|
114
|
+
native.set_keepalive(config.keepalive_interval.to_f, config.keepalive_timeout.to_f)
|
|
115
|
+
native.set_dispatch_queue_size(config.dispatch_queue_size)
|
|
116
|
+
native.set_log_requests(config.log_requests ? true : false)
|
|
117
|
+
native.set_compression(config.compression ? true : false)
|
|
118
|
+
native.set_reflection(config.reflection ? true : false)
|
|
119
|
+
native.set_reflection_descriptors(Reflection.file_descriptors(@router.service_names)) if config.reflection
|
|
120
|
+
native
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The proc the transport calls for every RPC:
|
|
124
|
+
# (path, metadata, call) → encoded response String, or nil if streamed
|
|
125
|
+
def build_callback
|
|
126
|
+
router = @router
|
|
127
|
+
call = ->(path, metadata, transport) { router.dispatch(path, metadata, transport) }
|
|
128
|
+
|
|
129
|
+
call = @configuration.interceptors.reverse.reduce(call) do |inner, interceptor|
|
|
130
|
+
lambda do |path, metadata, transport|
|
|
131
|
+
interceptor.call(path, metadata, -> { inner.call(path, metadata, transport) })
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
guarded = lambda do |path, metadata, transport|
|
|
136
|
+
call.call(path, metadata, transport)
|
|
137
|
+
rescue RpcError
|
|
138
|
+
raise
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
Tonic::Rails.log_exception(e, path)
|
|
141
|
+
raise RpcError.new("internal error", code: :internal)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
wrap_with_rails_executor(guarded)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Each RPC runs inside the Rails executor (or reloader, when code
|
|
148
|
+
# reloading is on): ActiveRecord connections are returned, query
|
|
149
|
+
# caches and CurrentAttributes are reset — the same isolation Rack
|
|
150
|
+
# requests and Active Job get.
|
|
151
|
+
def wrap_with_rails_executor(callback)
|
|
152
|
+
return callback unless defined?(::Rails.application) && ::Rails.application
|
|
153
|
+
|
|
154
|
+
app = ::Rails.application
|
|
155
|
+
wrapper = @configuration.reload_handlers ? app.reloader : app.executor
|
|
156
|
+
->(path, metadata, transport) { wrapper.wrap { callback.call(path, metadata, transport) } }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def spawn_worker(index)
|
|
160
|
+
native = @native
|
|
161
|
+
Thread.new do
|
|
162
|
+
Thread.current.name = "tonic-rails-dispatch-#{index}"
|
|
163
|
+
native.serve_worker
|
|
164
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
165
|
+
Tonic::Rails.log_exception(e, "dispatch thread #{index}")
|
|
166
|
+
stop
|
|
167
|
+
raise
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def trap_signals
|
|
172
|
+
%w[TERM INT].to_h { |signal| [signal, Signal.trap(signal) { stop }] }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def wait_for_stop(workers)
|
|
176
|
+
loop do
|
|
177
|
+
break if @stop_reader.wait_readable(1)
|
|
178
|
+
|
|
179
|
+
dead = workers.reject(&:alive?)
|
|
180
|
+
unless dead.empty?
|
|
181
|
+
@configuration.logger&.error("tonic-rails: #{dead.size} dispatch thread(s) died; shutting down")
|
|
182
|
+
break
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def shutdown(workers)
|
|
188
|
+
logger = @configuration.logger
|
|
189
|
+
@state = :stopping
|
|
190
|
+
Tonic::Rails._set_shutting_down(true)
|
|
191
|
+
logger&.info("tonic-rails: shutting down, draining for up to #{@configuration.drain_timeout}s")
|
|
192
|
+
|
|
193
|
+
@native.begin_shutdown
|
|
194
|
+
unless @native.wait_for_drain(@configuration.drain_timeout.to_f)
|
|
195
|
+
logger&.warn("tonic-rails: drain timeout expired; closing remaining connections")
|
|
196
|
+
end
|
|
197
|
+
@native.stop_workers
|
|
198
|
+
workers.each do |worker|
|
|
199
|
+
worker.join
|
|
200
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
201
|
+
# Already logged by the worker.
|
|
202
|
+
end
|
|
203
|
+
logger&.info("tonic-rails: stopped")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def log_startup
|
|
207
|
+
config = @configuration
|
|
208
|
+
logger = config.logger
|
|
209
|
+
return unless logger
|
|
210
|
+
|
|
211
|
+
if config.tls?
|
|
212
|
+
logger.info("tonic-rails: TLS enabled#{' (mutual TLS)' if config.tls_client_ca_path}")
|
|
213
|
+
else
|
|
214
|
+
logger.warn("tonic-rails: TLS is not configured; gRPC traffic is plaintext")
|
|
215
|
+
end
|
|
216
|
+
logger.info("tonic-rails: reflection enabled") if config.reflection
|
|
217
|
+
logger.info(
|
|
218
|
+
"tonic-rails: serving #{@router.service_names.join(', ')} on #{config.bind_address} " \
|
|
219
|
+
"(#{config.dispatch_threads} dispatch threads)",
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
namespace :tonic_rails do
|
|
6
|
+
desc "Start the gRPC server (boots the Rails app with its registered services)"
|
|
7
|
+
task serve: :environment do
|
|
8
|
+
# Eager-load so handlers registered via config.to_prepare and
|
|
9
|
+
# autoloaded constants are in place, as they would be in production.
|
|
10
|
+
Rails.application.eager_load! unless Rails.application.config.eager_load
|
|
11
|
+
Tonic::Rails.server.start
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
desc "List registered gRPC methods"
|
|
15
|
+
task routes: :environment do
|
|
16
|
+
router = Tonic::Rails.server.router
|
|
17
|
+
if router.empty?
|
|
18
|
+
puts "No gRPC services registered."
|
|
19
|
+
else
|
|
20
|
+
router.paths.sort.each { |path| puts path }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
desc "Generate Ruby protobuf classes (PROTO_DIR=app/protos RUBY_OUT=lib/grpc; requires protoc)"
|
|
25
|
+
task :compile_protos do
|
|
26
|
+
proto_dir = ENV.fetch("PROTO_DIR", "app/protos")
|
|
27
|
+
ruby_out = ENV.fetch("RUBY_OUT", "lib/grpc")
|
|
28
|
+
protos = Dir.glob(File.join(proto_dir, "**", "*.proto"))
|
|
29
|
+
abort "No .proto files found in #{proto_dir}/" if protos.empty?
|
|
30
|
+
abort "protoc not found on PATH. Install the Protocol Buffers compiler." unless system("protoc", "--version", out: File::NULL)
|
|
31
|
+
|
|
32
|
+
FileUtils.mkdir_p(ruby_out)
|
|
33
|
+
sh "protoc", "--ruby_out=#{ruby_out}", "-I#{proto_dir}", *protos
|
|
34
|
+
puts "Compiled #{protos.size} proto file(s) to #{ruby_out}/"
|
|
35
|
+
end
|
|
36
|
+
end
|