tonic-rails 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/Cargo.lock +69 -1
- data/README.md +257 -8
- data/ext/tonic_rails_native/Cargo.toml +9 -2
- data/ext/tonic_rails_native/src/client.rs +655 -0
- data/ext/tonic_rails_native/src/gvl.rs +22 -7
- data/ext/tonic_rails_native/src/lib.rs +17 -0
- data/ext/tonic_rails_native/src/net.rs +121 -6
- data/ext/tonic_rails_native/src/server.rs +42 -4
- data/lib/puma/plugin/tonic_rails.rb +5 -3
- data/lib/tonic/rails/base.rb +4 -1
- data/lib/tonic/rails/client.rb +371 -0
- data/lib/tonic/rails/cluster.rb +222 -0
- data/lib/tonic/rails/configuration.rb +32 -0
- data/lib/tonic/rails/instrumentation.rb +19 -4
- data/lib/tonic/rails/railtie.rb +5 -0
- data/lib/tonic/rails/rpc_error.rb +61 -0
- data/lib/tonic/rails/server.rb +74 -32
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +2 -0
- metadata +4 -1
|
@@ -0,0 +1,371 @@
|
|
|
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
|
+
# Stubs are built from the protobuf descriptors that
|
|
18
|
+
# `tonic_rails:compile_protos` (protoc --ruby_out) generates; no
|
|
19
|
+
# `_services_pb.rb` is needed. A failed call raises
|
|
20
|
+
# Tonic::Rails::RpcError.
|
|
21
|
+
#
|
|
22
|
+
# A client holds one HTTP/2 connection and is safe to share between
|
|
23
|
+
# threads, so create it once (a constant or an initializer). It keeps
|
|
24
|
+
# working across `fork`: each process connects on its own, with no
|
|
25
|
+
# hooks to call.
|
|
26
|
+
class Client
|
|
27
|
+
# A response with its headers and trailers; see `return_call:`.
|
|
28
|
+
Call = Data.define(:response, :headers, :trailers)
|
|
29
|
+
|
|
30
|
+
DEFAULT_MAX_MESSAGE_SIZE = 4 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
# @return [String] "host:port"
|
|
33
|
+
attr_reader :address
|
|
34
|
+
|
|
35
|
+
# @return [Numeric, nil] default deadline in seconds
|
|
36
|
+
attr_reader :deadline
|
|
37
|
+
|
|
38
|
+
# @param address [String] "host:port"
|
|
39
|
+
# @param tls [Boolean] false for plaintext, which must be explicit
|
|
40
|
+
# @param tls_ca_path [String, nil] CA certificates (PEM) to trust instead
|
|
41
|
+
# of the system's root certificates
|
|
42
|
+
# @param tls_cert_path [String, nil] client certificate (PEM), for mutual TLS
|
|
43
|
+
# @param tls_key_path [String, nil] client private key (PEM), for mutual TLS
|
|
44
|
+
# @param tls_server_name [String, nil] name to verify the server's
|
|
45
|
+
# certificate against, if not the host in `address`
|
|
46
|
+
# @param deadline [Numeric, nil] seconds each call may take, unless the
|
|
47
|
+
# call sets its own
|
|
48
|
+
# @param metadata [Hash] sent with every call (see Tonic::Rails::Metadata)
|
|
49
|
+
# @param max_message_size [Integer] largest message sent or received, in bytes
|
|
50
|
+
# @param connect_timeout [Numeric] seconds to wait for a connection
|
|
51
|
+
def initialize(address, tls: true, tls_ca_path: nil, tls_cert_path: nil, tls_key_path: nil,
|
|
52
|
+
tls_server_name: nil, deadline: nil, metadata: {}, max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
|
|
53
|
+
connect_timeout: 5)
|
|
54
|
+
Tonic::Rails.native_extension!
|
|
55
|
+
validate_address!(address)
|
|
56
|
+
validate_tls!(tls, tls_cert_path, tls_key_path)
|
|
57
|
+
positive!(:deadline, deadline) unless deadline.nil?
|
|
58
|
+
positive!(:connect_timeout, connect_timeout)
|
|
59
|
+
unless max_message_size.is_a?(Integer) && max_message_size.positive?
|
|
60
|
+
raise ArgumentError, "max_message_size must be a positive Integer"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@address = address
|
|
64
|
+
@deadline = deadline
|
|
65
|
+
@metadata = Metadata.encode(metadata)
|
|
66
|
+
@stubs = {}
|
|
67
|
+
tls_options = [read_pem(tls_ca_path, "CA"), read_pem(tls_cert_path, "certificate"),
|
|
68
|
+
read_pem(tls_key_path, "key"), tls_server_name]
|
|
69
|
+
@native = TonicRails::Native::Channel.new(
|
|
70
|
+
"#{tls ? 'https' : 'http'}://#{address}", max_message_size, (connect_timeout * 1000).ceil,
|
|
71
|
+
(tls_options if tls)
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A stub with one method per RPC of the service, named in snake_case
|
|
76
|
+
# (`GetItem` becomes `get_item`).
|
|
77
|
+
#
|
|
78
|
+
# @param service [String, Google::Protobuf::ServiceDescriptor, #service_name]
|
|
79
|
+
# the fully-qualified service name, its descriptor, or a grpc gem
|
|
80
|
+
# service class (`Demo::V1::ItemService::Service`)
|
|
81
|
+
# @return [Tonic::Rails::Client::Stub]
|
|
82
|
+
def stub(service)
|
|
83
|
+
descriptor = service_descriptor(service)
|
|
84
|
+
@stubs[descriptor.name] ||= Stub.for(descriptor).new(self)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def inspect
|
|
88
|
+
"#<#{self.class.name} #{address}>"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The deadline of the RPC this thread is handling, as a monotonic
|
|
92
|
+
# clock time; outgoing calls default to it.
|
|
93
|
+
# @api private
|
|
94
|
+
def self.current_deadline
|
|
95
|
+
Thread.current[:tonic_rails_deadline]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @api private
|
|
99
|
+
def self.with_deadline(deadline)
|
|
100
|
+
previous = Thread.current[:tonic_rails_deadline]
|
|
101
|
+
Thread.current[:tonic_rails_deadline] = deadline
|
|
102
|
+
yield
|
|
103
|
+
ensure
|
|
104
|
+
Thread.current[:tonic_rails_deadline] = previous
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Make a unary call. Stubs call this; use them instead.
|
|
108
|
+
# @api private
|
|
109
|
+
def unary(path, input, output, request, deadline: nil, metadata: {}, return_call: false)
|
|
110
|
+
message = request_message(path, input, request)
|
|
111
|
+
metadata = call_metadata(metadata)
|
|
112
|
+
timeout_ms = remaining_ms(deadline_at(deadline))
|
|
113
|
+
|
|
114
|
+
Instrumentation.instrument_request(path, address, metadata) do
|
|
115
|
+
code, error_message, response, headers, trailers, details =
|
|
116
|
+
@native.unary(path, input.encode(message), metadata, timeout_ms)
|
|
117
|
+
headers = decode_metadata(headers)
|
|
118
|
+
trailers = decode_metadata(trailers)
|
|
119
|
+
check_status!(code, error_message, trailers, details)
|
|
120
|
+
|
|
121
|
+
response = decode_response(output, response, path)
|
|
122
|
+
return_call ? Call.new(response: response, headers: headers, trailers: trailers) : response
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Start a server-streaming call. Stubs call this; use them instead.
|
|
127
|
+
# @api private
|
|
128
|
+
def server_streaming(path, input, output, request, deadline: nil, metadata: {})
|
|
129
|
+
message = request_message(path, input, request)
|
|
130
|
+
metadata = call_metadata(metadata)
|
|
131
|
+
# Fixed now, so the stream keeps the deadline of the RPC this
|
|
132
|
+
# thread is handling even if it's read later.
|
|
133
|
+
deadline_at = deadline_at(deadline)
|
|
134
|
+
|
|
135
|
+
ServerStream.new(path) do |stream, block|
|
|
136
|
+
timeout_ms = remaining_ms(deadline_at)
|
|
137
|
+
Instrumentation.instrument_request(path, address, metadata) do
|
|
138
|
+
native = @native.server_streaming(path, input.encode(message), metadata, timeout_ms)
|
|
139
|
+
begin
|
|
140
|
+
while (bytes = native.next)
|
|
141
|
+
block.call(decode_response(output, bytes, path))
|
|
142
|
+
end
|
|
143
|
+
code, error_message, _, headers, trailers, details = native.result
|
|
144
|
+
trailers = decode_metadata(trailers)
|
|
145
|
+
stream.finish(decode_metadata(headers), trailers)
|
|
146
|
+
check_status!(code, error_message, trailers, details)
|
|
147
|
+
ensure
|
|
148
|
+
native.close
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def validate_address!(address)
|
|
157
|
+
return if address.is_a?(String) && address.match?(%r{\A[^\s/]+:\d+\z})
|
|
158
|
+
|
|
159
|
+
raise ArgumentError, "address must be \"host:port\", got #{address.inspect}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def validate_tls!(tls, cert_path, key_path)
|
|
163
|
+
raise ArgumentError, "tls must be true or false" unless [true, false].include?(tls)
|
|
164
|
+
raise ArgumentError, "tls_cert_path and tls_key_path must be set together" if cert_path.nil? != key_path.nil?
|
|
165
|
+
raise ArgumentError, "tls_cert_path and tls_key_path need tls: true" if cert_path && !tls
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def positive!(name, value)
|
|
169
|
+
raise ArgumentError, "#{name} must be a positive number of seconds" unless value.is_a?(Numeric) && value.positive?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def read_pem(path, what)
|
|
173
|
+
return unless path
|
|
174
|
+
raise ArgumentError, "TLS #{what} file not found: #{path}" unless File.file?(path)
|
|
175
|
+
|
|
176
|
+
File.read(path)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def service_descriptor(service)
|
|
180
|
+
return service if service.is_a?(Google::Protobuf::ServiceDescriptor)
|
|
181
|
+
|
|
182
|
+
name = service.respond_to?(:service_name) ? service.service_name : service.to_s
|
|
183
|
+
descriptor = Google::Protobuf::DescriptorPool.generated_pool.lookup(name)
|
|
184
|
+
return descriptor if descriptor.is_a?(Google::Protobuf::ServiceDescriptor)
|
|
185
|
+
|
|
186
|
+
raise NotConfiguredError,
|
|
187
|
+
"#{name} is not a known protobuf service. Require its generated _pb.rb file (protoc --ruby_out) first."
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def request_message(path, input, request)
|
|
191
|
+
case request
|
|
192
|
+
when input then request
|
|
193
|
+
when Hash then input.new(request)
|
|
194
|
+
else raise TypeError, "#{path} expects a #{input} or Hash, got #{request.class}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def call_metadata(metadata)
|
|
199
|
+
metadata.empty? ? @metadata : @metadata.merge(Metadata.encode(metadata))
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# When the call must end, as a monotonic clock time: the call's own
|
|
203
|
+
# deadline, or else the earlier of the client's default and the
|
|
204
|
+
# deadline of the RPC being handled.
|
|
205
|
+
def deadline_at(deadline)
|
|
206
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
207
|
+
case deadline
|
|
208
|
+
when nil then [(now + @deadline if @deadline), self.class.current_deadline].compact.min
|
|
209
|
+
when Numeric then now + deadline
|
|
210
|
+
when Time then now + (deadline - Time.now)
|
|
211
|
+
else raise ArgumentError, "deadline must be seconds (Numeric) or a Time, got #{deadline.class}"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Milliseconds left until `deadline_at`, or nil for no deadline.
|
|
216
|
+
def remaining_ms(deadline_at)
|
|
217
|
+
return unless deadline_at
|
|
218
|
+
|
|
219
|
+
remaining = deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
220
|
+
raise RpcError.new("deadline exceeded", code: :deadline_exceeded) unless remaining.positive?
|
|
221
|
+
|
|
222
|
+
(remaining * 1000).ceil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def check_status!(code, message, trailers, details)
|
|
226
|
+
return if code.zero?
|
|
227
|
+
|
|
228
|
+
raise RpcError.new(message.empty? ? nil : message, code: code, metadata: trailers,
|
|
229
|
+
details: RpcError.decode_details(details))
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Wire-form [name, value] pairs to a Hash: `-bin` values decoded,
|
|
233
|
+
# repeated names collected into an Array, and transport headers
|
|
234
|
+
# (content-type, grpc-*) dropped.
|
|
235
|
+
def decode_metadata(pairs)
|
|
236
|
+
pairs.each_with_object({}) do |(name, value), metadata|
|
|
237
|
+
next if name.start_with?("grpc-") || Metadata::RESERVED.include?(name)
|
|
238
|
+
|
|
239
|
+
value = value.unpack1("m") if name.end_with?("-bin")
|
|
240
|
+
metadata[name] = metadata.key?(name) ? [*metadata[name], value] : value
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def decode_response(output, bytes, path)
|
|
245
|
+
output.decode(bytes)
|
|
246
|
+
rescue Google::Protobuf::ParseError
|
|
247
|
+
raise RpcError.new("invalid protobuf response from #{path}", code: :internal)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Calls an RPC's methods. Built per service from its descriptor; get
|
|
251
|
+
# one with Client#stub.
|
|
252
|
+
class Stub
|
|
253
|
+
class << self
|
|
254
|
+
# @api private
|
|
255
|
+
attr_reader :service_name
|
|
256
|
+
|
|
257
|
+
# @api private
|
|
258
|
+
def for(descriptor)
|
|
259
|
+
Class.new(self) do
|
|
260
|
+
@service_name = descriptor.name
|
|
261
|
+
descriptor.each { |method| define_rpc(method) }
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def inspect
|
|
266
|
+
service_name ? "#{Stub.name}(#{service_name})" : super
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
private
|
|
270
|
+
|
|
271
|
+
def define_rpc(method)
|
|
272
|
+
path = "/#{service_name}/#{method.name}"
|
|
273
|
+
name = ActiveSupport::Inflector.underscore(method.name)
|
|
274
|
+
if method.client_streaming
|
|
275
|
+
define_method(name) do |*, **|
|
|
276
|
+
raise NotImplementedError,
|
|
277
|
+
"#{path} is a client-streaming RPC; the tonic-rails client makes unary and server-streaming calls"
|
|
278
|
+
end
|
|
279
|
+
return
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
input = method.input_type.msgclass
|
|
283
|
+
output = method.output_type.msgclass
|
|
284
|
+
if method.server_streaming
|
|
285
|
+
define_method(name) do |request = nil, deadline: nil, metadata: {}, **fields|
|
|
286
|
+
request = rpc_request(name, request, fields)
|
|
287
|
+
@client.server_streaming(path, input, output, request, deadline: deadline, metadata: metadata)
|
|
288
|
+
end
|
|
289
|
+
else
|
|
290
|
+
# `get_item(id: 1)` works as well as `get_item({ id: 1 })`.
|
|
291
|
+
define_method(name) do |request = nil, deadline: nil, metadata: {}, return_call: false, **fields|
|
|
292
|
+
request = rpc_request(name, request, fields)
|
|
293
|
+
@client.unary(path, input, output, request, deadline: deadline, metadata: metadata,
|
|
294
|
+
return_call: return_call)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# @return [Tonic::Rails::Client]
|
|
301
|
+
attr_reader :client
|
|
302
|
+
|
|
303
|
+
def initialize(client)
|
|
304
|
+
@client = client
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def inspect
|
|
308
|
+
"#<#{self.class.inspect} #{client.address}>"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
private
|
|
312
|
+
|
|
313
|
+
def rpc_request(name, request, fields)
|
|
314
|
+
unless request.nil? || fields.empty?
|
|
315
|
+
raise ArgumentError, "#{name}: pass the request or its fields, not both (#{fields.keys.join(', ')})"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
request || fields
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# The responses of a server-streaming call. Nothing is sent until
|
|
323
|
+
# it's read; then messages are yielded as they arrive, a few at most
|
|
324
|
+
# buffered ahead of the reader.
|
|
325
|
+
#
|
|
326
|
+
# items.list_items(category: "books").each { |item| ... }
|
|
327
|
+
# items.list_items(category: "books").first(10)
|
|
328
|
+
#
|
|
329
|
+
# Stopping early (`break`, `first(n)`, an exception) cancels the call.
|
|
330
|
+
# An error from the server raises Tonic::Rails::RpcError after the
|
|
331
|
+
# messages that came before it. A stream can be read once.
|
|
332
|
+
class ServerStream
|
|
333
|
+
include Enumerable
|
|
334
|
+
|
|
335
|
+
# @return [Hash, nil] response headers, once the stream has ended
|
|
336
|
+
attr_reader :headers
|
|
337
|
+
|
|
338
|
+
# @return [Hash, nil] trailing metadata, once the stream has ended
|
|
339
|
+
attr_reader :trailers
|
|
340
|
+
|
|
341
|
+
# @api private
|
|
342
|
+
def initialize(path, &read)
|
|
343
|
+
@path = path
|
|
344
|
+
@read = read
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# @yieldparam response [Google::Protobuf::AbstractMessage]
|
|
348
|
+
# @return [self, Enumerator] an Enumerator without a block
|
|
349
|
+
def each(&block)
|
|
350
|
+
return enum_for(:each) unless block
|
|
351
|
+
raise Error, "the #{@path} stream has already been read" unless @read
|
|
352
|
+
|
|
353
|
+
read = @read
|
|
354
|
+
@read = nil
|
|
355
|
+
read.call(self, block)
|
|
356
|
+
self
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# @api private
|
|
360
|
+
def finish(headers, trailers)
|
|
361
|
+
@headers = headers
|
|
362
|
+
@trailers = trailers
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def inspect
|
|
366
|
+
"#<#{self.class.name} #{@path}>"
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Tonic
|
|
6
|
+
module Rails
|
|
7
|
+
# Multi-process mode (`config.processes > 1`).
|
|
8
|
+
#
|
|
9
|
+
# The process that calls Server#start becomes a supervisor: it binds the
|
|
10
|
+
# listening socket, then forks one worker per process. Workers inherit
|
|
11
|
+
# the loaded app (copy-on-write) and the socket, and each runs a full
|
|
12
|
+
# server (its own tokio runtime, created after the fork) accepting on
|
|
13
|
+
# it. The supervisor never starts a runtime itself, so nothing
|
|
14
|
+
# multi-threaded crosses a fork.
|
|
15
|
+
#
|
|
16
|
+
# Sharing one socket rather than binding with SO_REUSEPORT per worker
|
|
17
|
+
# means connections spread across workers on every Unix (macOS doesn't
|
|
18
|
+
# load-balance SO_REUSEPORT), port 0 works, a bind error surfaces once,
|
|
19
|
+
# and connections waiting in the backlog survive a worker's death.
|
|
20
|
+
#
|
|
21
|
+
# Lifecycle:
|
|
22
|
+
# - startup fails if a worker exits before it's serving (usually a
|
|
23
|
+
# configuration or boot error, which a retry wouldn't fix)
|
|
24
|
+
# - a worker that dies later is logged and replaced, with backoff if
|
|
25
|
+
# replacements keep failing to boot
|
|
26
|
+
# - stopping (TERM/INT or Server#stop) closes the supervisor's copy of
|
|
27
|
+
# the socket and sends TERM to every worker, which flips its health to
|
|
28
|
+
# NOT_SERVING and drains; workers still running after `drain_timeout`
|
|
29
|
+
# plus a margin are killed
|
|
30
|
+
# - a worker whose supervisor disappears stops itself
|
|
31
|
+
class Cluster
|
|
32
|
+
# Seconds to wait beyond drain_timeout before killing a worker.
|
|
33
|
+
STOP_MARGIN = 5
|
|
34
|
+
MAX_BACKOFF = 30
|
|
35
|
+
|
|
36
|
+
Worker = Struct.new(:index, :pid, :ready_io, :ready)
|
|
37
|
+
|
|
38
|
+
def initialize(server, configuration, stop_signal)
|
|
39
|
+
@server = server
|
|
40
|
+
@configuration = configuration
|
|
41
|
+
@stop_signal = stop_signal
|
|
42
|
+
@workers = []
|
|
43
|
+
@failures = Hash.new(0)
|
|
44
|
+
@respawn_at = {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Run the workers until stopped.
|
|
48
|
+
# @yield once every worker is serving
|
|
49
|
+
def run(install_signal_handlers:)
|
|
50
|
+
@listener = Socket.for_fd(TonicRails::Native.bind_listener(@configuration.bind_address))
|
|
51
|
+
@listener.autoclose = true
|
|
52
|
+
previous_handlers = install_signal_handlers ? trap_signals : {}
|
|
53
|
+
@configuration.processes.times { |index| @workers << spawn(index) }
|
|
54
|
+
return unless booted?
|
|
55
|
+
|
|
56
|
+
@started = true
|
|
57
|
+
yield if block_given?
|
|
58
|
+
supervise
|
|
59
|
+
ensure
|
|
60
|
+
@listener&.close
|
|
61
|
+
stop_workers
|
|
62
|
+
previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def logger = @configuration.logger
|
|
68
|
+
|
|
69
|
+
def trap_signals
|
|
70
|
+
server = @server
|
|
71
|
+
%w[TERM INT].to_h { |signal| [signal, Signal.trap(signal) { server.stop }] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def spawn(index)
|
|
75
|
+
reader, writer = IO.pipe
|
|
76
|
+
pid = fork do
|
|
77
|
+
reader.close
|
|
78
|
+
@workers.each { |worker| worker&.ready_io&.close }
|
|
79
|
+
run_worker(index, writer)
|
|
80
|
+
end
|
|
81
|
+
writer.close
|
|
82
|
+
Worker.new(index, pid, reader, false)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# In the worker process.
|
|
86
|
+
def run_worker(index, ready)
|
|
87
|
+
Process.setproctitle("tonic-rails worker #{index}")
|
|
88
|
+
supervisor = Process.ppid
|
|
89
|
+
Thread.new do
|
|
90
|
+
sleep 1 while Process.ppid == supervisor
|
|
91
|
+
@server.stop
|
|
92
|
+
end
|
|
93
|
+
@server.run_worker(@listener, index) do
|
|
94
|
+
ready.write("1")
|
|
95
|
+
ready.close
|
|
96
|
+
end
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
Tonic::Rails.log_exception(e, "worker #{index}")
|
|
99
|
+
exit 1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Wait until every worker is serving: true once they are, false if a
|
|
103
|
+
# stop was requested first. Raises if a worker exited before serving.
|
|
104
|
+
def booted?
|
|
105
|
+
until @workers.all?(&:ready)
|
|
106
|
+
return false if @stop_signal.pop(timeout: 0.05)
|
|
107
|
+
|
|
108
|
+
@workers.each do |worker|
|
|
109
|
+
next if ready?(worker)
|
|
110
|
+
|
|
111
|
+
status = reap(worker)
|
|
112
|
+
raise Error, "tonic-rails worker #{worker.index} exited during startup (#{describe(status)})" if status
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def supervise
|
|
119
|
+
loop do
|
|
120
|
+
break if @stop_signal.pop(timeout: 0.5)
|
|
121
|
+
|
|
122
|
+
@workers.each_index { |index| supervise_worker(index) }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def supervise_worker(index)
|
|
127
|
+
worker = @workers[index]
|
|
128
|
+
if worker.nil?
|
|
129
|
+
@workers[index] = spawn(index) if monotonic >= @respawn_at[index]
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
ready?(worker)
|
|
134
|
+
status = reap(worker)
|
|
135
|
+
return unless status
|
|
136
|
+
|
|
137
|
+
replace(worker, status)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A worker died after startup. Replace it right away if it had been
|
|
141
|
+
# serving; if replacements keep dying before they serve, back off.
|
|
142
|
+
def replace(worker, status)
|
|
143
|
+
index = worker.index
|
|
144
|
+
@failures[index] = worker.ready ? 0 : @failures[index] + 1
|
|
145
|
+
delay = @failures[index].zero? ? 0 : [2**(@failures[index] - 1), MAX_BACKOFF].min
|
|
146
|
+
logger&.error(
|
|
147
|
+
"tonic-rails: worker #{index} (pid #{worker.pid}) exited unexpectedly (#{describe(status)}); " \
|
|
148
|
+
"starting a replacement#{" in #{delay}s" if delay.positive?}",
|
|
149
|
+
)
|
|
150
|
+
@workers[index] = nil
|
|
151
|
+
@respawn_at[index] = monotonic + delay
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def ready?(worker)
|
|
155
|
+
return true if worker.ready
|
|
156
|
+
|
|
157
|
+
case worker.ready_io.read_nonblock(1, exception: false)
|
|
158
|
+
when String
|
|
159
|
+
worker.ready = true
|
|
160
|
+
worker.ready_io.close
|
|
161
|
+
logger&.info("tonic-rails: worker #{worker.index} (pid #{worker.pid}) serving") if @started
|
|
162
|
+
true
|
|
163
|
+
else
|
|
164
|
+
false
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The worker's exit status if it has exited, otherwise nil.
|
|
169
|
+
def reap(worker)
|
|
170
|
+
_, status = Process.waitpid2(worker.pid, Process::WNOHANG)
|
|
171
|
+
worker.ready_io.close if status
|
|
172
|
+
status
|
|
173
|
+
rescue Errno::ECHILD
|
|
174
|
+
worker.ready_io.close
|
|
175
|
+
:unknown
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def stop_workers
|
|
179
|
+
live = @workers.compact
|
|
180
|
+
return if live.empty?
|
|
181
|
+
|
|
182
|
+
logger&.info(
|
|
183
|
+
"tonic-rails: shutting down #{live.size} workers, draining for up to #{@configuration.drain_timeout}s",
|
|
184
|
+
)
|
|
185
|
+
live.each { |worker| signal(worker, :TERM) }
|
|
186
|
+
deadline = monotonic + @configuration.drain_timeout.to_f + STOP_MARGIN
|
|
187
|
+
until (live = live.reject { |worker| reap(worker) }).empty?
|
|
188
|
+
if monotonic > deadline
|
|
189
|
+
logger&.warn("tonic-rails: #{live.size} worker(s) still running after the drain timeout; killing them")
|
|
190
|
+
live.each { |worker| kill(worker) }
|
|
191
|
+
break
|
|
192
|
+
end
|
|
193
|
+
sleep 0.05
|
|
194
|
+
end
|
|
195
|
+
@workers = []
|
|
196
|
+
logger&.info("tonic-rails: stopped")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def kill(worker)
|
|
200
|
+
signal(worker, :KILL)
|
|
201
|
+
Process.wait(worker.pid)
|
|
202
|
+
rescue Errno::ECHILD
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def signal(worker, name)
|
|
207
|
+
Process.kill(name, worker.pid)
|
|
208
|
+
rescue Errno::ESRCH
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def describe(status)
|
|
213
|
+
return "status unknown" unless status.is_a?(Process::Status)
|
|
214
|
+
return "signal #{Signal.signame(status.termsig)}" if status.signaled?
|
|
215
|
+
|
|
216
|
+
"exit status #{status.exitstatus}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -26,6 +26,22 @@ module Tonic
|
|
|
26
26
|
# (RAILS_MAX_THREADS). Default 4.
|
|
27
27
|
attr_accessor :dispatch_threads
|
|
28
28
|
|
|
29
|
+
# Server processes. Ruby runs one thread at a time per process, so a
|
|
30
|
+
# CPU-bound service uses one core however many dispatch threads it
|
|
31
|
+
# has; N processes use N. Above 1, the app is loaded once and forked
|
|
32
|
+
# into N workers that share the listening socket, supervised by the
|
|
33
|
+
# process that started the server. Unix only. Default 1.
|
|
34
|
+
attr_accessor :processes
|
|
35
|
+
|
|
36
|
+
# Called in each worker process after it's forked, with the worker's
|
|
37
|
+
# index (multi-process mode only), to reopen connections that can't
|
|
38
|
+
# be shared across fork:
|
|
39
|
+
#
|
|
40
|
+
# config.on_worker_boot = ->(index) { Redis.current.disconnect! }
|
|
41
|
+
#
|
|
42
|
+
# Active Record reconnects by itself. Default nil.
|
|
43
|
+
attr_accessor :on_worker_boot
|
|
44
|
+
|
|
29
45
|
# RPCs that may wait for a free dispatch thread. Beyond this the
|
|
30
46
|
# server answers RESOURCE_EXHAUSTED immediately. Default 128.
|
|
31
47
|
attr_accessor :dispatch_queue_size
|
|
@@ -110,6 +126,8 @@ module Tonic
|
|
|
110
126
|
@logger = Logger.new($stdout)
|
|
111
127
|
@worker_threads = nil
|
|
112
128
|
@dispatch_threads = 4
|
|
129
|
+
@processes = 1
|
|
130
|
+
@on_worker_boot = nil
|
|
113
131
|
@dispatch_queue_size = 128
|
|
114
132
|
@tls_cert_path = nil
|
|
115
133
|
@tls_key_path = nil
|
|
@@ -132,6 +150,10 @@ module Tonic
|
|
|
132
150
|
@health_check_interval = 5
|
|
133
151
|
end
|
|
134
152
|
|
|
153
|
+
def multi_process?
|
|
154
|
+
processes.is_a?(Integer) && processes > 1
|
|
155
|
+
end
|
|
156
|
+
|
|
135
157
|
def tls?
|
|
136
158
|
!tls_cert_path.nil? && !tls_key_path.nil?
|
|
137
159
|
end
|
|
@@ -140,6 +162,7 @@ module Tonic
|
|
|
140
162
|
def validate!
|
|
141
163
|
positive_integer!(:dispatch_threads)
|
|
142
164
|
positive_integer!(:dispatch_queue_size)
|
|
165
|
+
positive_integer!(:processes)
|
|
143
166
|
positive_integer!(:max_message_size)
|
|
144
167
|
non_negative_integer!(:max_connections)
|
|
145
168
|
non_negative_integer!(:concurrency_limit_per_connection)
|
|
@@ -152,12 +175,20 @@ module Tonic
|
|
|
152
175
|
|
|
153
176
|
validate_callables!
|
|
154
177
|
validate_grpc_web!
|
|
178
|
+
validate_processes!
|
|
155
179
|
|
|
156
180
|
self
|
|
157
181
|
end
|
|
158
182
|
|
|
159
183
|
private
|
|
160
184
|
|
|
185
|
+
def validate_processes!
|
|
186
|
+
return unless multi_process?
|
|
187
|
+
return if Process.respond_to?(:fork)
|
|
188
|
+
|
|
189
|
+
raise ArgumentError, "processes > 1 needs fork(), which this platform lacks; run one server process per core instead"
|
|
190
|
+
end
|
|
191
|
+
|
|
161
192
|
def validate_grpc_web!
|
|
162
193
|
%i[grpc_web_origins grpc_web_allowed_headers grpc_web_exposed_headers].each do |name|
|
|
163
194
|
value = public_send(name)
|
|
@@ -169,6 +200,7 @@ module Tonic
|
|
|
169
200
|
|
|
170
201
|
def validate_callables!
|
|
171
202
|
raise ArgumentError, "health_check must respond to #call" if health_check && !health_check.respond_to?(:call)
|
|
203
|
+
raise ArgumentError, "on_worker_boot must respond to #call" if on_worker_boot && !on_worker_boot.respond_to?(:call)
|
|
172
204
|
unless health_check_interval.is_a?(Numeric) && health_check_interval.positive?
|
|
173
205
|
raise ArgumentError, "health_check_interval must be a positive number, got #{health_check_interval.inspect}"
|
|
174
206
|
end
|