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.
@@ -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