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 +4 -4
- data/CHANGELOG.md +22 -1
- data/README.md +57 -8
- data/lib/puma/plugin/tonic_rails.rb +5 -3
- 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/cluster.rb +222 -0
- data/lib/tonic/rails/configuration.rb +32 -0
- data/lib/tonic/rails/railtie.rb +5 -0
- data/lib/tonic/rails/server.rb +74 -32
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 83071391bb348390f9956aebc1bfc867e92624981e5ceb2e3b71750698d0fe37
|
|
4
|
+
data.tar.gz: f23b34b99227c1c4e45ae18718f59338c4671ca59fc8ed9f57d605c238dd7698
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
###
|
|
495
|
+
### Threads or processes
|
|
494
496
|
|
|
495
497
|
Handlers run on `dispatch_threads` Ruby threads. Blocking I/O (database,
|
|
496
|
-
HTTP) releases
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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.
|
|
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 +
|
|
92
|
+
drain + 10
|
|
91
93
|
end
|
|
92
94
|
|
|
93
95
|
def monitor_grpc
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
data/lib/tonic/rails/railtie.rb
CHANGED
|
@@ -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"
|
data/lib/tonic/rails/server.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
@
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
data/lib/tonic/rails/version.rb
CHANGED
data/lib/tonic-rails.rb
CHANGED
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
|
+
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
|