tonic-rails 0.4.0-aarch64-linux-gnu → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 91be536ad3a1e8c1ca97da57820ed90a10a7655acc8ac4f8fc3f08cdf0458632
4
- data.tar.gz: b77391b48dd895a85efcbd483ef73600afbb545424f9b497b8ea37f3a1041911
3
+ metadata.gz: 83071391bb348390f9956aebc1bfc867e92624981e5ceb2e3b71750698d0fe37
4
+ data.tar.gz: f23b34b99227c1c4e45ae18718f59338c4671ca59fc8ed9f57d605c238dd7698
5
5
  SHA512:
6
- metadata.gz: 3ad30d527ac745b30d80ef763ef119b37c6b9951f85ae8f3f1ec56b2ee0a0dd262868d950dbfe415487f9ac7d69f0c95009a8b9f1f4e63f519958ace65c60be4
7
- data.tar.gz: c3f0fadfc968526d91ec3382c08837e367fcc0a6c1ba519176492fef3c2c84f6a661a824910a7df79892c742c3686702dc51ca934370c7e6581ebb472a1a5991
6
+ metadata.gz: 6318e95784a88fc5969ef9b4efe6cc6302b841f9bd0ea7db694ea364224e45a2913e8b451d071a5b8efe54a6466e297ff61c73ed12d13529d21394d38351ab56
7
+ data.tar.gz: f72d308a70f5a8fd446fb61ae35affd0a02f468d70f9ab49d80ca11dca673d7b0ecb0d5d067e0665b8d1a5f1a8e542c9a7713c71dd5cf02425d0ca69bb7fceda
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ minor versions may contain breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - Multi-process mode: `config.processes = 4` runs four server processes
15
+ on one port, so services that spend their time in Ruby use four cores
16
+ instead of one. The app loads once and forks the workers. They share
17
+ the listening socket, with new connections going to the least busy
18
+ worker. Workers that die are replaced, and SIGTERM drains them all.
19
+ `on_worker_boot` runs in each worker after fork. Works with
20
+ `tonic_rails:serve` and the Puma plugin; Unix only. Closes #23.
21
+ - Benchmark: `PROCESSES` and `CONNECTIONS` for `bench/run_bench.sh`, and
22
+ `-connections` for the Go client.
23
+
24
+ ### Changed
25
+
26
+ - The Puma plugin waits `drain_timeout` + 10 seconds (was + 5) for the
27
+ gRPC server to stop before killing it, which leaves a multi-process
28
+ server time to stop its own workers.
29
+
10
30
  ## [0.4.0] - 2026-10-11
11
31
 
12
32
  ### Added
@@ -125,7 +145,8 @@ First public release.
125
145
  - Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
126
146
  macOS (x86_64, arm64), Ruby 3.3 to 4.0.
127
147
 
128
- [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.4.0...HEAD
148
+ [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.5.0...HEAD
149
+ [0.5.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.4.0...v0.5.0
129
150
  [0.4.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.3.0...v0.4.0
130
151
  [0.3.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.2.0...v0.3.0
131
152
  [0.2.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...v0.2.0
data/README.md CHANGED
@@ -470,6 +470,8 @@ end
470
470
  |--------|---------|-------------|
471
471
  | `bind_address` | `"0.0.0.0:50051"` | Address to listen on |
472
472
  | `dispatch_threads` | `4` | Ruby threads running handlers; match your database pool |
473
+ | `processes` | `1` | Server processes sharing the port (see [Threads or processes](#threads-or-processes)); Unix only |
474
+ | `on_worker_boot` | `nil` | Callable run in each worker process after fork, given its index |
473
475
  | `dispatch_queue_size` | `128` | Calls that may wait for a thread; beyond this, `RESOURCE_EXHAUSTED` |
474
476
  | `worker_threads` | CPU count | Tokio threads for the transport |
475
477
  | `max_message_size` | 4 MiB | Request and response limit (also caps decompressed size) |
@@ -490,14 +492,60 @@ end
490
492
  | `log_requests` | `false` | Log one line per call through `logger` (unexpected exceptions are always logged) |
491
493
  | `auto_start` | `false` | Start in a thread of `rails server`; for development (see [auto_start](#auto_start)) |
492
494
 
493
- ### Sizing threads
495
+ ### Threads or processes
494
496
 
495
497
  Handlers run on `dispatch_threads` Ruby threads. Blocking I/O (database,
496
- HTTP) releases the GVL, so more threads means more concurrent I/O. CPU
497
- work does not parallelise within a process. Set threads to the
498
- concurrency your I/O needs, size the database pool to match, and add
499
- processes for CPU-heavy services: the listener uses `SO_REUSEPORT`, so
500
- several processes can bind the same port.
498
+ HTTP) releases Ruby's global VM lock, so more threads means more
499
+ concurrent I/O. Ruby code itself runs one thread at a time per process,
500
+ though: a service that spends its time in Ruby (building large
501
+ responses, serializing, computing) tops out at one core however many
502
+ threads it has.
503
+
504
+ So:
505
+
506
+ - **Mostly waiting on I/O?** Add threads. They're cheap, and one process
507
+ goes a long way. Size the database pool to match.
508
+ - **Mostly running Ruby?** Add processes. `processes = 4` runs four
509
+ copies of the server on the same port and uses four cores.
510
+
511
+ ```ruby
512
+ config.tonic_rails.processes = ENV.fetch("GRPC_PROCESSES", 4).to_i
513
+ config.tonic_rails.dispatch_threads = 5 # per process
514
+ ```
515
+
516
+ Most services mix both. A reasonable start is one process per core and
517
+ a handful of threads in each. Every process has its own database pool,
518
+ so the database sees up to `processes × dispatch_threads` connections.
519
+
520
+ With `processes` above 1, `bin/rails tonic_rails:serve` loads the app
521
+ once and forks the workers, which share memory until they write to it.
522
+ The process you started supervises them:
523
+
524
+ - Workers share one listening socket, and a worker with fewer open
525
+ connections picks up new ones first. gRPC clients keep connections
526
+ open and send every call over them, so a single client connection
527
+ is served by a single worker: spread load with several connections
528
+ or several clients.
529
+ - If a worker dies, it's logged and replaced.
530
+ - SIGTERM or SIGINT to the supervisor drains every worker (health
531
+ reports `NOT_SERVING`, in-flight calls finish) and it exits once all
532
+ have stopped. Workers stop on their own if the supervisor disappears.
533
+ - If a worker fails while starting (a bad configuration, say), the
534
+ server doesn't start.
535
+
536
+ Active Record reconnects in each worker by itself. Reopen anything else
537
+ that can't be shared across `fork` in `on_worker_boot`:
538
+
539
+ ```ruby
540
+ config.tonic_rails.on_worker_boot = ->(index) { MyQueueClient.reconnect! }
541
+ ```
542
+
543
+ Health checks, `health_check`, `serving!`, `not_serving!` and `stats`
544
+ work per worker: calling `serving!` or `not_serving!` in the supervisor
545
+ after the server has started doesn't reach the workers.
546
+
547
+ Multi-process mode needs `fork()`, so it isn't available on Windows. Run
548
+ one server process per core there, each on its own port.
501
549
 
502
550
  ### Running with Puma
503
551
 
@@ -513,7 +561,8 @@ web workers. It starts after Puma boots and drains gracefully when Puma
513
561
  stops or restarts. If the gRPC process dies, Puma shuts down too, so your
514
562
  supervisor restarts both. It works with `rails server`, plain `puma`, and
515
563
  `preload_app!`. (Not on Windows, which has no `fork()`: run
516
- `tonic_rails:serve` as its own process there.)
564
+ `tonic_rails:serve` as its own process there.) With `processes` above 1,
565
+ that child process supervises the gRPC workers.
517
566
 
518
567
  For larger deployments, a dedicated `bin/rails tonic_rails:serve` process
519
568
  (scaled separately) is still the most flexible option.
@@ -523,7 +572,7 @@ For larger deployments, a dedicated `bin/rails tonic_rails:serve` process
523
572
  `auto_start = true` starts the gRPC server in a background thread of
524
573
  `rails server` (never in consoles, rake tasks or tests). It's handy in
525
574
  development; in production use the Puma plugin or a dedicated process.
526
- Don't combine it with the plugin.
575
+ Don't combine it with the plugin, and it can't run multiple `processes`.
527
576
 
528
577
  ## Operations
529
578
 
@@ -9,7 +9,8 @@ require "puma/plugin"
9
9
  #
10
10
  # The gRPC server runs as one child process of the Puma master, in single
11
11
  # and cluster mode alike, so it has its own GVL and never competes with web
12
- # workers. It starts once Puma has booted, drains gracefully when Puma
12
+ # workers. With `config.processes > 1`, that child supervises the gRPC
13
+ # worker processes. It starts once Puma has booted, drains gracefully when Puma
13
14
  # stops or restarts, stops itself if Puma goes away, and if it dies
14
15
  # unexpectedly Puma shuts down too, so a supervisor can restart both.
15
16
  Puma::Plugin.create do
@@ -62,7 +63,8 @@ Puma::Plugin.create do
62
63
  end
63
64
 
64
65
  # Puma fires its stop hooks from its TERM trap handler, so this must
65
- # not block indefinitely: allow the server's drain timeout plus a margin,
66
+ # not block indefinitely: allow the server's drain timeout plus a margin
67
+ # (longer than the one a multi-process server gives its own workers),
66
68
  # then kill it.
67
69
  def stop_grpc
68
70
  pid = @grpc_pid
@@ -87,7 +89,7 @@ Puma::Plugin.create do
87
89
 
88
90
  def stop_timeout
89
91
  drain = defined?(Tonic::Rails) ? Tonic::Rails.configuration.drain_timeout.to_f : 10.0
90
- drain + 5
92
+ drain + 10
91
93
  end
92
94
 
93
95
  def monitor_grpc
@@ -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
@@ -31,6 +31,11 @@ module Tonic
31
31
  # tasks, or test runs. With Puma, prefer `plugin :tonic_rails`
32
32
  # (lib/puma/plugin/tonic_rails.rb), which also covers cluster mode.
33
33
  if configuration.auto_start && defined?(::Rails::Server)
34
+ if configuration.multi_process?
35
+ raise Error, "auto_start can't run multiple processes (processes = #{configuration.processes}); " \
36
+ "use `plugin :tonic_rails` in config/puma.rb or `bin/rails tonic_rails:serve`"
37
+ end
38
+
34
39
  server = Tonic::Rails.server
35
40
  thread = Thread.new do
36
41
  Thread.current.name = "tonic-rails"
@@ -36,8 +36,9 @@ module Tonic
36
36
  # Start serving and block until stopped.
37
37
  # @param install_signal_handlers [Boolean] trap TERM and INT to stop.
38
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)
39
+ # @yield once the server is accepting connections (with
40
+ # `processes > 1`, once every worker is)
41
+ def start(install_signal_handlers: true, &)
41
42
  @lock.synchronize do
42
43
  raise Error, "server is already running" unless @state == :stopped
43
44
 
@@ -50,40 +51,31 @@ module Tonic
50
51
 
51
52
  @configuration.validate!
52
53
  Tonic::Rails.native_extension!
53
- Tonic::Rails._set_shutting_down(false)
54
54
  # A Queue rather than a self-pipe: pushing to it is safe from trap
55
55
  # handlers, and unlike pipes it can be waited on with a timeout
56
56
  # on every platform (Windows select() only handles sockets).
57
57
  @stop_signal = Thread::Queue.new
58
58
  stop if @stop_requested # requested while we were starting up
59
- @native = build_native_server
60
- @native.start_server
61
- log_pump = spawn_log_pump
62
- @health.attach(@native)
63
- @state = :running
64
-
65
- request_log = subscribe_request_logger
66
- previous_handlers = install_signal_handlers ? trap_signals : {}
67
- workers = Array.new(@configuration.dispatch_threads) { |i| spawn_worker(i) }
68
- log_startup
69
- yield self if block_given?
70
-
71
- wait_for_stop(workers)
72
- @health.detach
73
- shutdown(workers)
59
+ if @configuration.multi_process?
60
+ run_cluster(install_signal_handlers, &)
61
+ else
62
+ serve(install_signal_handlers: install_signal_handlers, &)
63
+ end
74
64
  ensure
75
- ActiveSupport::Notifications.unsubscribe(request_log) if request_log
76
- previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
77
- @health.detach
78
- @native&.stop_server
79
- stop_log_pump(log_pump)
80
- @native = nil
81
65
  @stop_signal = nil
82
- Tonic::Rails._set_shutting_down(false)
83
66
  @state = :stopped
84
67
  end
85
68
  end
86
69
 
70
+ # @api private
71
+ # The body of a worker process in multi-process mode (see Cluster):
72
+ # serve on the listening socket the parent bound.
73
+ def run_worker(listener, index, &)
74
+ @worker_index = index
75
+ @configuration.on_worker_boot&.call(index)
76
+ serve(install_signal_handlers: true, listener: listener, &)
77
+ end
78
+
87
79
  # Request a graceful stop. Safe to call from signal handlers and
88
80
  # other threads; returns immediately.
89
81
  def stop
@@ -125,6 +117,55 @@ module Tonic
125
117
 
126
118
  private
127
119
 
120
+ def run_cluster(install_signal_handlers)
121
+ @state = :running
122
+ Cluster.new(self, @configuration, @stop_signal).run(install_signal_handlers: install_signal_handlers) do
123
+ log_startup
124
+ yield self if block_given?
125
+ end
126
+ end
127
+
128
+ def serve(install_signal_handlers:, listener: nil)
129
+ Tonic::Rails._set_shutting_down(false)
130
+ @native = build_native_server
131
+ @native.set_listener_fd(listener.fileno) if listener
132
+ @native.start_server
133
+ # The transport holds its own copy now; let go of ours, so the
134
+ # socket closes once every worker has stopped accepting.
135
+ listener&.close
136
+ log_pump = spawn_log_pump
137
+ @health.attach(@native)
138
+ @state = :running
139
+
140
+ request_log = subscribe_request_logger
141
+ previous_handlers = install_signal_handlers ? trap_signals : {}
142
+ workers = Array.new(@configuration.dispatch_threads) { |i| spawn_worker(i) }
143
+ log_startup unless worker?
144
+ yield self if block_given?
145
+
146
+ wait_for_stop(workers)
147
+ @health.detach
148
+ shutdown(workers)
149
+ ensure
150
+ ActiveSupport::Notifications.unsubscribe(request_log) if request_log
151
+ previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
152
+ @health.detach
153
+ @native&.stop_server
154
+ stop_log_pump(log_pump)
155
+ @native = nil
156
+ Tonic::Rails._set_shutting_down(false)
157
+ end
158
+
159
+ # True in a worker process of a multi-process server, whose parent
160
+ # logs startup and shutdown for all of them.
161
+ def worker?
162
+ !@worker_index.nil?
163
+ end
164
+
165
+ def worker_label
166
+ worker? ? "worker #{@worker_index}: " : ""
167
+ end
168
+
128
169
  def build_native_server
129
170
  config = @configuration
130
171
  native = TonicRails::Native::Server.new(config.bind_address, config.max_message_size)
@@ -267,13 +308,15 @@ module Tonic
267
308
 
268
309
  def shutdown(workers)
269
310
  logger = @configuration.logger
311
+ # In a worker, the parent logs shutdown once for all of them.
312
+ lifecycle = worker? ? nil : logger
270
313
  @state = :stopping
271
314
  Tonic::Rails._set_shutting_down(true)
272
- logger&.info("tonic-rails: shutting down, draining for up to #{@configuration.drain_timeout}s")
315
+ lifecycle&.info("tonic-rails: shutting down, draining for up to #{@configuration.drain_timeout}s")
273
316
 
274
317
  @native.begin_shutdown
275
318
  unless @native.wait_for_drain(@configuration.drain_timeout.to_f)
276
- logger&.warn("tonic-rails: drain timeout expired; closing remaining connections")
319
+ logger&.warn("tonic-rails: #{worker_label}drain timeout expired; closing remaining connections")
277
320
  end
278
321
  @native.stop_workers
279
322
  workers.each do |worker|
@@ -281,7 +324,7 @@ module Tonic
281
324
  rescue Exception # rubocop:disable Lint/RescueException
282
325
  # Already logged by the worker.
283
326
  end
284
- logger&.info("tonic-rails: stopped")
327
+ lifecycle&.info("tonic-rails: stopped")
285
328
  end
286
329
 
287
330
  def log_grpc_web(logger, config)
@@ -306,10 +349,9 @@ module Tonic
306
349
  end
307
350
  logger.info("tonic-rails: reflection enabled") if config.reflection
308
351
  log_grpc_web(logger, config)
309
- logger.info(
310
- "tonic-rails: serving #{@router.service_names.join(', ')} on #{config.bind_address} " \
311
- "(#{config.dispatch_threads} dispatch threads)",
312
- )
352
+ threads = "#{config.dispatch_threads} dispatch threads"
353
+ threads = "#{config.processes} processes × #{threads} each" if config.multi_process?
354
+ logger.info("tonic-rails: serving #{@router.service_names.join(', ')} on #{config.bind_address} (#{threads})")
313
355
  end
314
356
  end
315
357
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Tonic
4
4
  module Rails
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
data/lib/tonic-rails.rb CHANGED
@@ -19,6 +19,7 @@ require "tonic/rails/instrumentation"
19
19
  require "tonic/rails/base"
20
20
  require "tonic/rails/health"
21
21
  require "tonic/rails/reflection"
22
+ require "tonic/rails/cluster"
22
23
  require "tonic/rails/server"
23
24
 
24
25
  module Tonic
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tonic-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: aarch64-linux-gnu
6
6
  authors:
7
7
  - Matt Tanous
@@ -67,6 +67,7 @@ files:
67
67
  - lib/tonic/rails/3.4/tonic_rails_native.so
68
68
  - lib/tonic/rails/4.0/tonic_rails_native.so
69
69
  - lib/tonic/rails/base.rb
70
+ - lib/tonic/rails/cluster.rb
70
71
  - lib/tonic/rails/configuration.rb
71
72
  - lib/tonic/rails/health.rb
72
73
  - lib/tonic/rails/instrumentation.rb