tonic-rails 0.3.0-x86_64-linux-gnu → 0.5.0-x86_64-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 +32 -1
- data/README.md +98 -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 +65 -0
- data/lib/tonic/rails/railtie.rb +5 -0
- data/lib/tonic/rails/server.rb +101 -37
- 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: 5aa7de65014581bfcd5575510bdcd6ec9ec4bd0ab81473e49077188093ff436a
|
|
4
|
+
data.tar.gz: eaf8038cfa3d9b62e16d575b4e56001bb9873cdc30ed19f9ab43967869d907f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cc4417d8c41fb9fb0e363ffb0605ac745d9b564e63a413b7b86e6d2d8a0c861f035f2553a6df8602466ead880cd58d1cc50bfc798536e97bc967d67b7b107b0f
|
|
7
|
+
data.tar.gz: 484f38fa65491cf4e34e32e30e35af03ee7b5de3e354b33e4c23fbbe1df50b55a7dc9d19411f59ef1db04767fca9eb69d90facdae940e79f4f0cd6f92c77700c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ 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
|
+
|
|
30
|
+
## [0.4.0] - 2026-10-11
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- gRPC-Web: `config.grpc_web = true` serves browser clients (unary and
|
|
35
|
+
server-streaming RPCs, binary and text encodings) without a proxy, on the
|
|
36
|
+
same port as native gRPC, with CORS via `grpc_web_origins`,
|
|
37
|
+
`grpc_web_allowed_headers` and `grpc_web_exposed_headers`. Closes #22.
|
|
38
|
+
|
|
10
39
|
## [0.3.0] - 2026-10-11
|
|
11
40
|
|
|
12
41
|
### Added
|
|
@@ -116,7 +145,9 @@ First public release.
|
|
|
116
145
|
- Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
|
|
117
146
|
macOS (x86_64, arm64), Ruby 3.3 to 4.0.
|
|
118
147
|
|
|
119
|
-
[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
|
|
150
|
+
[0.4.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.3.0...v0.4.0
|
|
120
151
|
[0.3.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.2.0...v0.3.0
|
|
121
152
|
[0.2.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...v0.2.0
|
|
122
153
|
[0.1.0]: https://github.com/CodingAnarchy/tonic-rails/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -363,6 +363,44 @@ Tonic::Rails.configure do |config|
|
|
|
363
363
|
end
|
|
364
364
|
```
|
|
365
365
|
|
|
366
|
+
## Browser clients (gRPC-Web)
|
|
367
|
+
|
|
368
|
+
Browsers can't speak native gRPC, but they can speak
|
|
369
|
+
[gRPC-Web](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md).
|
|
370
|
+
Turn it on and your existing handlers serve web frontends directly, with
|
|
371
|
+
no Envoy or other proxy:
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
Tonic::Rails.configure do |config|
|
|
375
|
+
config.grpc_web = true
|
|
376
|
+
config.grpc_web_origins = ["https://app.example.com"] # CORS; omit for same-origin only
|
|
377
|
+
config.grpc_web_exposed_headers = %w[x-request-id] # response metadata browsers may read
|
|
378
|
+
end
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Then call it from the browser with any gRPC-Web client, for example
|
|
382
|
+
[Connect](https://connectrpc.com/docs/web/using-clients):
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { createClient } from "@connectrpc/connect";
|
|
386
|
+
import { createGrpcWebTransport } from "@connectrpc/connect-web";
|
|
387
|
+
import { ItemService } from "./gen/demo/v1/items_pb";
|
|
388
|
+
|
|
389
|
+
const client = createClient(ItemService, createGrpcWebTransport({ baseUrl: "https://api.example.com" }));
|
|
390
|
+
const item = await client.getItem({ id: 1n });
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
- Unary and server-streaming RPCs work; browsers can't do client or
|
|
394
|
+
bidirectional streaming.
|
|
395
|
+
- Errors, response headers and trailers reach the browser as usual.
|
|
396
|
+
- Native gRPC clients keep working on the same port. Enabling gRPC-Web
|
|
397
|
+
also accepts HTTP/1.1, which browsers use without TLS; over HTTPS they
|
|
398
|
+
negotiate HTTP/2.
|
|
399
|
+
- `grpc_web_origins` sets CORS: listed origins may call with credentials
|
|
400
|
+
(cookies); `"*"` allows any origin without credentials (logged as a
|
|
401
|
+
warning). `grpc_web_allowed_headers` adds request headers beyond the
|
|
402
|
+
standard ones (`authorization` is already allowed).
|
|
403
|
+
|
|
366
404
|
## Testing handlers
|
|
367
405
|
|
|
368
406
|
`Tonic::Rails::Testing` calls a handler in-process, with real protobuf
|
|
@@ -432,6 +470,8 @@ end
|
|
|
432
470
|
|--------|---------|-------------|
|
|
433
471
|
| `bind_address` | `"0.0.0.0:50051"` | Address to listen on |
|
|
434
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 |
|
|
435
475
|
| `dispatch_queue_size` | `128` | Calls that may wait for a thread; beyond this, `RESOURCE_EXHAUSTED` |
|
|
436
476
|
| `worker_threads` | CPU count | Tokio threads for the transport |
|
|
437
477
|
| `max_message_size` | 4 MiB | Request and response limit (also caps decompressed size) |
|
|
@@ -441,6 +481,9 @@ end
|
|
|
441
481
|
| `tls_client_ca_path` | `nil` | PEM CA bundle; requires client certificates (mutual TLS) |
|
|
442
482
|
| `drain_timeout` | `10` | Seconds to let in-flight calls finish on shutdown |
|
|
443
483
|
| `keepalive_interval`, `keepalive_timeout` | `15`, `5` | HTTP/2 PING interval and timeout, seconds |
|
|
484
|
+
| `grpc_web` | `false` | Serve gRPC-Web for browser clients (see [Browser clients](#browser-clients-grpc-web)) |
|
|
485
|
+
| `grpc_web_origins` | `[]` | Browser origins allowed via CORS; `"*"` for any (no credentials) |
|
|
486
|
+
| `grpc_web_allowed_headers`, `grpc_web_exposed_headers` | `[]` | Extra CORS request / response headers |
|
|
444
487
|
| `health_check` | `nil` | Callable; while false or raising, health reports `NOT_SERVING` |
|
|
445
488
|
| `health_check_interval` | `5` | Seconds between health checks |
|
|
446
489
|
| `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
|
|
@@ -449,14 +492,60 @@ end
|
|
|
449
492
|
| `log_requests` | `false` | Log one line per call through `logger` (unexpected exceptions are always logged) |
|
|
450
493
|
| `auto_start` | `false` | Start in a thread of `rails server`; for development (see [auto_start](#auto_start)) |
|
|
451
494
|
|
|
452
|
-
###
|
|
495
|
+
### Threads or processes
|
|
453
496
|
|
|
454
497
|
Handlers run on `dispatch_threads` Ruby threads. Blocking I/O (database,
|
|
455
|
-
HTTP) releases
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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.
|
|
460
549
|
|
|
461
550
|
### Running with Puma
|
|
462
551
|
|
|
@@ -472,7 +561,8 @@ web workers. It starts after Puma boots and drains gracefully when Puma
|
|
|
472
561
|
stops or restarts. If the gRPC process dies, Puma shuts down too, so your
|
|
473
562
|
supervisor restarts both. It works with `rails server`, plain `puma`, and
|
|
474
563
|
`preload_app!`. (Not on Windows, which has no `fork()`: run
|
|
475
|
-
`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.
|
|
476
566
|
|
|
477
567
|
For larger deployments, a dedicated `bin/rails tonic_rails:serve` process
|
|
478
568
|
(scaled separately) is still the most flexible option.
|
|
@@ -482,7 +572,7 @@ For larger deployments, a dedicated `bin/rails tonic_rails:serve` process
|
|
|
482
572
|
`auto_start = true` starts the gRPC server in a background thread of
|
|
483
573
|
`rails server` (never in consoles, rake tasks or tests). It's handy in
|
|
484
574
|
development; in production use the Puma plugin or a dedicated process.
|
|
485
|
-
Don't combine it with the plugin
|
|
575
|
+
Don't combine it with the plugin, and it can't run multiple `processes`.
|
|
486
576
|
|
|
487
577
|
## Operations
|
|
488
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
|
|
@@ -66,6 +82,25 @@ module Tonic
|
|
|
66
82
|
# API schema; keep it off in production. Default false.
|
|
67
83
|
attr_accessor :reflection
|
|
68
84
|
|
|
85
|
+
# Accept gRPC-Web requests, so browser apps can call services
|
|
86
|
+
# directly (unary and server-streaming RPCs). Also enables HTTP/1.1,
|
|
87
|
+
# which browsers use when there's no TLS. Default false.
|
|
88
|
+
attr_accessor :grpc_web
|
|
89
|
+
|
|
90
|
+
# Browser origins allowed to call gRPC-Web services (CORS), e.g.
|
|
91
|
+
# ["https://app.example.com"]. Empty = same-origin only. "*" allows
|
|
92
|
+
# any origin, without credentials (cookies). Default [].
|
|
93
|
+
attr_accessor :grpc_web_origins
|
|
94
|
+
|
|
95
|
+
# Extra request headers browsers may send (beyond content-type,
|
|
96
|
+
# x-grpc-web, x-user-agent, grpc-timeout and authorization).
|
|
97
|
+
attr_accessor :grpc_web_allowed_headers
|
|
98
|
+
|
|
99
|
+
# Extra response headers browsers may read, e.g. custom response
|
|
100
|
+
# metadata (grpc-status, grpc-message and grpc-status-details-bin are
|
|
101
|
+
# always exposed).
|
|
102
|
+
attr_accessor :grpc_web_exposed_headers
|
|
103
|
+
|
|
69
104
|
# HTTP/2 PING interval and ack timeout, in seconds. Defaults 15 / 5.
|
|
70
105
|
attr_accessor :keepalive_interval, :keepalive_timeout
|
|
71
106
|
|
|
@@ -91,6 +126,8 @@ module Tonic
|
|
|
91
126
|
@logger = Logger.new($stdout)
|
|
92
127
|
@worker_threads = nil
|
|
93
128
|
@dispatch_threads = 4
|
|
129
|
+
@processes = 1
|
|
130
|
+
@on_worker_boot = nil
|
|
94
131
|
@dispatch_queue_size = 128
|
|
95
132
|
@tls_cert_path = nil
|
|
96
133
|
@tls_key_path = nil
|
|
@@ -105,10 +142,18 @@ module Tonic
|
|
|
105
142
|
@keepalive_interval = 15
|
|
106
143
|
@keepalive_timeout = 5
|
|
107
144
|
@reload_handlers = false
|
|
145
|
+
@grpc_web = false
|
|
146
|
+
@grpc_web_origins = []
|
|
147
|
+
@grpc_web_allowed_headers = []
|
|
148
|
+
@grpc_web_exposed_headers = []
|
|
108
149
|
@health_check = nil
|
|
109
150
|
@health_check_interval = 5
|
|
110
151
|
end
|
|
111
152
|
|
|
153
|
+
def multi_process?
|
|
154
|
+
processes.is_a?(Integer) && processes > 1
|
|
155
|
+
end
|
|
156
|
+
|
|
112
157
|
def tls?
|
|
113
158
|
!tls_cert_path.nil? && !tls_key_path.nil?
|
|
114
159
|
end
|
|
@@ -117,6 +162,7 @@ module Tonic
|
|
|
117
162
|
def validate!
|
|
118
163
|
positive_integer!(:dispatch_threads)
|
|
119
164
|
positive_integer!(:dispatch_queue_size)
|
|
165
|
+
positive_integer!(:processes)
|
|
120
166
|
positive_integer!(:max_message_size)
|
|
121
167
|
non_negative_integer!(:max_connections)
|
|
122
168
|
non_negative_integer!(:concurrency_limit_per_connection)
|
|
@@ -128,14 +174,33 @@ module Tonic
|
|
|
128
174
|
raise ArgumentError, "tls_client_ca_path requires tls_cert_path and tls_key_path" if tls_client_ca_path && !tls?
|
|
129
175
|
|
|
130
176
|
validate_callables!
|
|
177
|
+
validate_grpc_web!
|
|
178
|
+
validate_processes!
|
|
131
179
|
|
|
132
180
|
self
|
|
133
181
|
end
|
|
134
182
|
|
|
135
183
|
private
|
|
136
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
|
+
|
|
192
|
+
def validate_grpc_web!
|
|
193
|
+
%i[grpc_web_origins grpc_web_allowed_headers grpc_web_exposed_headers].each do |name|
|
|
194
|
+
value = public_send(name)
|
|
195
|
+
next if value.is_a?(Array) && value.all?(String)
|
|
196
|
+
|
|
197
|
+
raise ArgumentError, "#{name} must be an Array of Strings, got #{value.inspect}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
137
201
|
def validate_callables!
|
|
138
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)
|
|
139
204
|
unless health_check_interval.is_a?(Numeric) && health_check_interval.positive?
|
|
140
205
|
raise ArgumentError, "health_check_interval must be a positive number, got #{health_check_interval.inspect}"
|
|
141
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,24 +117,84 @@ 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)
|
|
131
172
|
native.on_request(build_callback)
|
|
132
173
|
native.set_routes(@router.paths)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
174
|
+
apply_connection_settings(native, config)
|
|
175
|
+
apply_features(native, config)
|
|
176
|
+
native
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def apply_connection_settings(native, config)
|
|
137
180
|
native.set_max_connections(config.max_connections)
|
|
138
181
|
native.set_concurrency_limit_per_connection(config.concurrency_limit_per_connection)
|
|
139
182
|
native.set_worker_threads(config.worker_threads || 0)
|
|
140
183
|
native.set_keepalive(config.keepalive_interval.to_f, config.keepalive_timeout.to_f)
|
|
141
184
|
native.set_dispatch_queue_size(config.dispatch_queue_size)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def apply_features(native, config)
|
|
188
|
+
if config.tls?
|
|
189
|
+
native.set_tls(config.tls_cert_path, config.tls_key_path)
|
|
190
|
+
native.set_tls_client_ca(config.tls_client_ca_path) if config.tls_client_ca_path
|
|
191
|
+
end
|
|
142
192
|
native.set_compression(config.compression ? true : false)
|
|
143
193
|
native.set_reflection(config.reflection ? true : false)
|
|
144
194
|
native.set_reflection_descriptors(Reflection.file_descriptors(@router.service_names)) if config.reflection
|
|
145
|
-
|
|
195
|
+
return unless config.grpc_web
|
|
196
|
+
|
|
197
|
+
native.set_grpc_web(config.grpc_web_origins, config.grpc_web_allowed_headers, config.grpc_web_exposed_headers)
|
|
146
198
|
end
|
|
147
199
|
|
|
148
200
|
# The proc the transport calls for every RPC:
|
|
@@ -256,13 +308,15 @@ module Tonic
|
|
|
256
308
|
|
|
257
309
|
def shutdown(workers)
|
|
258
310
|
logger = @configuration.logger
|
|
311
|
+
# In a worker, the parent logs shutdown once for all of them.
|
|
312
|
+
lifecycle = worker? ? nil : logger
|
|
259
313
|
@state = :stopping
|
|
260
314
|
Tonic::Rails._set_shutting_down(true)
|
|
261
|
-
|
|
315
|
+
lifecycle&.info("tonic-rails: shutting down, draining for up to #{@configuration.drain_timeout}s")
|
|
262
316
|
|
|
263
317
|
@native.begin_shutdown
|
|
264
318
|
unless @native.wait_for_drain(@configuration.drain_timeout.to_f)
|
|
265
|
-
logger&.warn("tonic-rails: drain timeout expired; closing remaining connections")
|
|
319
|
+
logger&.warn("tonic-rails: #{worker_label}drain timeout expired; closing remaining connections")
|
|
266
320
|
end
|
|
267
321
|
@native.stop_workers
|
|
268
322
|
workers.each do |worker|
|
|
@@ -270,7 +324,17 @@ module Tonic
|
|
|
270
324
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
271
325
|
# Already logged by the worker.
|
|
272
326
|
end
|
|
273
|
-
|
|
327
|
+
lifecycle&.info("tonic-rails: stopped")
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def log_grpc_web(logger, config)
|
|
331
|
+
return unless config.grpc_web
|
|
332
|
+
|
|
333
|
+
origins = config.grpc_web_origins
|
|
334
|
+
logger.info("tonic-rails: gRPC-Web enabled (#{origins.empty? ? 'same-origin only' : "origins: #{origins.join(', ')}"})")
|
|
335
|
+
return unless origins.include?("*")
|
|
336
|
+
|
|
337
|
+
logger.warn("tonic-rails: gRPC-Web allows any origin; any website can call these services from a visitor's browser")
|
|
274
338
|
end
|
|
275
339
|
|
|
276
340
|
def log_startup
|
|
@@ -284,10 +348,10 @@ module Tonic
|
|
|
284
348
|
logger.warn("tonic-rails: TLS is not configured; gRPC traffic is plaintext")
|
|
285
349
|
end
|
|
286
350
|
logger.info("tonic-rails: reflection enabled") if config.reflection
|
|
287
|
-
logger
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
)
|
|
351
|
+
log_grpc_web(logger, config)
|
|
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})")
|
|
291
355
|
end
|
|
292
356
|
end
|
|
293
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: x86_64-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
|