gritz-core 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +3 -1
- data/lib/gritz/cli.rb +24 -5
- data/lib/gritz/configuration.rb +8 -3
- data/lib/gritz/core/version.rb +1 -1
- data/lib/gritz/core.rb +2 -1
- data/lib/gritz/middleware/exception_mapper.rb +2 -0
- data/lib/gritz/supervisor/launcher.rb +11 -2
- data/lib/gritz/supervisor/listener.rb +25 -0
- data/lib/gritz/supervisor/master.rb +10 -7
- data/lib/gritz/testing/server.rb +93 -0
- data/lib/gritz/testing/transport_contract.rb +271 -0
- data/lib/gritz/worker/runner.rb +5 -4
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc43859887cac5ffa9e137f2cab8911a4a6ef79ed4c522380d55869446c360a9
|
|
4
|
+
data.tar.gz: adfbe678ac9b1af0f507cad8fb69c16bfea7acbe4a422bacfce1e6b90747f34b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c1d9ef947490cfa7c5832cca9d1c20f0c7e093798e56c4d3b0b261c80279ae1a55f2b747ea2f9c42af99c126ac91a3c55098d18a186d61a5ab362e1caad0b1d
|
|
7
|
+
data.tar.gz: 6e859a121f10896fb4e38f8cbf669e33ce504637026aa2269d338f003a64f02190b8299b59e145a4909bd5cf6cbd0d62fbab3fac630952154e880cd900cf0014
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
- Run Async workers through the same supervisor, CLI and real-server test helper as Native workers.
|
|
6
|
+
- Retain one inherited listener across forked workers and fresh master replacement, including ephemeral ports and recycling.
|
|
7
|
+
- Share adapter wire tests through `Gritz::Testing::TransportContract` without loading optional test dependencies in production.
|
|
8
|
+
- Preserve Async deadline errors through the standard exception middleware.
|
|
9
|
+
|
|
3
10
|
## 0.5.0
|
|
4
11
|
|
|
5
12
|
- Migrate Gruf controllers and server interceptors through `Gritz::Compat::Gruf`, with a configuration conversion tool.
|
data/README.md
CHANGED
|
@@ -6,10 +6,12 @@ Transport-independent controllers, routing, middleware, configuration, forked-wo
|
|
|
6
6
|
require "gritz/core"
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
Use [gritz](https://github.com/gritzrpc/gritz) for the default framework and executable, or combine this gem with [gritz-native](https://github.com/gritzrpc/gritz-native) for the official grpc gem adapter. The
|
|
9
|
+
Use [gritz](https://github.com/gritzrpc/gritz) for the default framework and executable, or combine this gem with [gritz-native](https://github.com/gritzrpc/gritz-native) for the official grpc gem adapter. The experimental [gritz-async](https://github.com/gritzrpc/gritz-async) adapter runs RPCs in fibers and supports inherited TCP listeners without installing the official grpc gem.
|
|
10
10
|
|
|
11
11
|
Controller tests can load `gritz/testing/rspec` or `gritz/testing/minitest` and run without a network or the grpc gem. See the [controller examples](https://github.com/gritzrpc/gritz#controllers) and [configuration guide](docs/guides/configuration.md).
|
|
12
12
|
|
|
13
|
+
`Gritz::Testing::Server` selects the configured adapter for real-socket tests; require that adapter's entrypoint first. Adapter suites explicitly require `gritz/testing/transport_contract` to run shared wire checks with RSpec, the official grpc client and googleapis-common-protos-types as test dependencies. Production eager loading skips this shared test suite.
|
|
14
|
+
|
|
13
15
|
Supervised servers support phased replacement, fresh-interpreter hot reexec, request/memory/lifetime recycling, Admin HTTP probes and process-wide Prometheus totals. The CLI keeps one lifecycle owner across master generations; application controllers and gRPC resources stay in serving processes.
|
|
14
16
|
|
|
15
17
|
`Gritz::Client.define` creates lazy, fork-safe client constants with deadline propagation, selected request headers and middleware around complete streams. See the [client guide](docs/guides/clients.md). Optional [gritz-otel](https://github.com/gritzrpc/gritz-otel) adds server/client spans and worker OTLP metrics after fork.
|
data/lib/gritz/cli.rb
CHANGED
|
@@ -16,7 +16,8 @@ module Gritz
|
|
|
16
16
|
RUBY
|
|
17
17
|
|
|
18
18
|
def self.child_command(argv)
|
|
19
|
-
|
|
19
|
+
library = defined?(Transport::Async) ? "gritz/async" : "gritz/core"
|
|
20
|
+
[RbConfig.ruby, "-I", $LOAD_PATH.join(File::PATH_SEPARATOR), "-r#{library}", "-e", CHILD_BOOTSTRAP, "--", *argv]
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
def initialize(stdout: $stdout, stderr: $stderr, env: ENV, status_io: nil, owner_io: nil, launch: false, launch_command: nil)
|
|
@@ -63,7 +64,9 @@ module Gritz
|
|
|
63
64
|
|
|
64
65
|
path ||= "config/gritz.rb" if File.file?("config/gritz.rb")
|
|
65
66
|
guard = ForkGuard.activate(mode: :record) if command != "routes"
|
|
66
|
-
|
|
67
|
+
if command != "routes" && Gem.loaded_specs.key?("gritz-native") && !defined?(Transport::Async)
|
|
68
|
+
require "gritz/native"
|
|
69
|
+
end
|
|
67
70
|
config = Configuration.load(path: path, env: @env, overrides: overrides)
|
|
68
71
|
if command == "check"
|
|
69
72
|
config.preload! if config.preload_app?
|
|
@@ -71,10 +74,12 @@ module Gritz
|
|
|
71
74
|
return 1 unless guard.violations.empty?
|
|
72
75
|
|
|
73
76
|
config.validate_runtime!
|
|
77
|
+
require "gritz/#{config.transport}"
|
|
74
78
|
Router.new(controllers: config.controllers, strict: config.strict_routes, logger: logger)
|
|
75
79
|
@stdout.puts "Configuration and fork safety checks passed"
|
|
76
80
|
elsif command == "start"
|
|
77
81
|
config.validate_runtime!
|
|
82
|
+
require "gritz/#{config.transport}"
|
|
78
83
|
if config.workers.positive? && config.fork_mode == :clean
|
|
79
84
|
raise guard.violations.first if config.fork_guard == :raise && !guard.violations.empty?
|
|
80
85
|
|
|
@@ -83,14 +88,27 @@ module Gritz
|
|
|
83
88
|
@stdout.sync = true if @stdout.respond_to?(:sync=)
|
|
84
89
|
@owner_channel&.write(type: "configured", pid: Process.pid, workers: config.workers, bind: config.bind,
|
|
85
90
|
admin_bind: config.admin_bind, min_ready_workers: config.min_ready_workers, pid_file: config.pid_file,
|
|
86
|
-
reexec_timeout: config.reexec_timeout, drain_delay: config.drain_delay, shutdown_timeout: config.shutdown_timeout
|
|
91
|
+
reexec_timeout: config.reexec_timeout, drain_delay: config.drain_delay, shutdown_timeout: config.shutdown_timeout,
|
|
92
|
+
listener_strategy: config.listener_strategy.to_s)
|
|
93
|
+
if @owner_channel && config.listener_strategy == :inherited_fd
|
|
94
|
+
until @owner_channel.flush
|
|
95
|
+
raise ConfigurationError, "listener owner closed its control channel" if @owner_channel.closed?
|
|
96
|
+
|
|
97
|
+
@owner_channel.io.wait_writable(0.01)
|
|
98
|
+
end
|
|
99
|
+
begin
|
|
100
|
+
listener = @owner_channel.io.recv_io(Socket)
|
|
101
|
+
rescue IOError, SocketError => e
|
|
102
|
+
raise ConfigurationError, "listener owner failed to pass its socket: #{e.message}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
87
105
|
if config.workers.positive?
|
|
88
|
-
return Supervisor::Master.new(config, logger: logger, status_io: @status_io, owner_channel: @owner_channel).run
|
|
106
|
+
return Supervisor::Master.new(config, logger: logger, status_io: @status_io, owner_channel: @owner_channel, listener:).run
|
|
89
107
|
end
|
|
90
108
|
|
|
91
109
|
ForkGuard.deactivate
|
|
92
110
|
config.preload! if config.preload_app?
|
|
93
|
-
return Worker::Runner.new(index: 0, config: config, logger: logger, owner_channel: @owner_channel).run
|
|
111
|
+
return Worker::Runner.new(index: 0, config: config, logger: logger, owner_channel: @owner_channel, listener:).run
|
|
94
112
|
else
|
|
95
113
|
config.preload! if config.preload_app?
|
|
96
114
|
router = Router.new(controllers: config.controllers, strict: config.strict_routes, logger: logger)
|
|
@@ -107,6 +125,7 @@ module Gritz
|
|
|
107
125
|
ensure
|
|
108
126
|
ForkGuard.deactivate if guard
|
|
109
127
|
@owner_channel&.close
|
|
128
|
+
listener&.close unless listener&.closed?
|
|
110
129
|
end
|
|
111
130
|
end
|
|
112
131
|
end
|
data/lib/gritz/configuration.rb
CHANGED
|
@@ -117,8 +117,11 @@ module Gritz
|
|
|
117
117
|
# Fail before allocating transport resources for unsupported release features.
|
|
118
118
|
def validate_runtime!
|
|
119
119
|
validate!
|
|
120
|
-
|
|
121
|
-
raise ConfigurationError, "
|
|
120
|
+
supported = transport == :native ? %i[reuseport] : %i[reuseport inherited_fd]
|
|
121
|
+
raise ConfigurationError, "#{transport} does not support listener_strategy #{listener_strategy}" unless supported.include?(listener_strategy)
|
|
122
|
+
if transport != :native && fork_mode == :grpc_fork_support
|
|
123
|
+
raise ConfigurationError, "grpc_fork_support requires transport :native"
|
|
124
|
+
end
|
|
122
125
|
unless metrics_backend == :pipe || (metrics_backend == :otlp && metrics_recorder_factory)
|
|
123
126
|
raise ConfigurationError, "metrics_backend requires an installed recorder (:pipe is built in)"
|
|
124
127
|
end
|
|
@@ -126,7 +129,9 @@ module Gritz
|
|
|
126
129
|
|
|
127
130
|
unless worker_recycle.empty?
|
|
128
131
|
raise ConfigurationError, "worker_recycle requires workers > 0" unless workers.positive?
|
|
129
|
-
|
|
132
|
+
if bind.end_with?(":0") && listener_strategy != :inherited_fd
|
|
133
|
+
raise ConfigurationError, "worker_recycle requires a fixed bind port"
|
|
134
|
+
end
|
|
130
135
|
end
|
|
131
136
|
raise ConfigurationError, "grpc_fork_support requires workers > 0" if workers.zero? && fork_mode != :clean
|
|
132
137
|
raise ConfigurationError, "at least one controller must be registered" if controllers.empty?
|
data/lib/gritz/core/version.rb
CHANGED
data/lib/gritz/core.rb
CHANGED
|
@@ -13,7 +13,8 @@ module Gritz
|
|
|
13
13
|
LOADER.inflector.inflect("dsl" => "DSL", "cli" => "CLI")
|
|
14
14
|
LOADER.push_dir(__dir__, namespace: Gritz)
|
|
15
15
|
LOADER.ignore(__FILE__, File.join(__dir__, "errors.rb"), File.join(__dir__, "core/version.rb"))
|
|
16
|
-
LOADER.do_not_eager_load(File.join(__dir__, "testing/rspec.rb"), File.join(__dir__, "testing/minitest.rb")
|
|
16
|
+
LOADER.do_not_eager_load(File.join(__dir__, "testing/rspec.rb"), File.join(__dir__, "testing/minitest.rb"),
|
|
17
|
+
File.join(__dir__, "testing/transport_contract.rb"))
|
|
17
18
|
LOADER.setup
|
|
18
19
|
end
|
|
19
20
|
end
|
|
@@ -27,6 +27,8 @@ module Gritz
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
case e.class.name
|
|
30
|
+
when "Async::GRPC::DeadlineExceededError"
|
|
31
|
+
raise Errors::DeadlineExceeded, "deadline exceeded"
|
|
30
32
|
when "ActiveRecord::RecordNotFound"
|
|
31
33
|
raise Errors::NotFound, "record not found"
|
|
32
34
|
when "ActiveRecord::RecordInvalid"
|
|
@@ -138,6 +138,10 @@ module Gritz
|
|
|
138
138
|
def configure_generation(generation, metadata)
|
|
139
139
|
return if generation.metadata || @stopping
|
|
140
140
|
|
|
141
|
+
if metadata[:listener_strategy] && !%w[reuseport inherited_fd].include?(metadata[:listener_strategy])
|
|
142
|
+
raise ConfigurationError, "Invalid master listener_strategy"
|
|
143
|
+
end
|
|
144
|
+
|
|
141
145
|
%i[admin_bind bind pid_file].each do |field|
|
|
142
146
|
raise ConfigurationError, "Missing master #{field}" unless metadata[field].is_a?(String)
|
|
143
147
|
end
|
|
@@ -149,7 +153,7 @@ module Gritz
|
|
|
149
153
|
raise ConfigurationError, "Invalid master #{field}" unless value.is_a?(Numeric) && value.finite? && value >= 0
|
|
150
154
|
end
|
|
151
155
|
if @metadata
|
|
152
|
-
%i[admin_bind bind pid_file].each do |field|
|
|
156
|
+
%i[admin_bind bind pid_file listener_strategy].each do |field|
|
|
153
157
|
raise ConfigurationError, "USR2 cannot change #{field}" unless metadata[field] == @metadata[field]
|
|
154
158
|
end
|
|
155
159
|
else
|
|
@@ -160,6 +164,10 @@ module Gritz
|
|
|
160
164
|
end
|
|
161
165
|
generation.metadata = metadata
|
|
162
166
|
generation.deadline = [generation.deadline, monotonic + metadata[:reexec_timeout]].min
|
|
167
|
+
if metadata[:listener_strategy] == "inherited_fd"
|
|
168
|
+
@listener ||= Listener.bind(metadata[:bind])
|
|
169
|
+
generation.channel.io.send_io(@listener)
|
|
170
|
+
end
|
|
163
171
|
end
|
|
164
172
|
|
|
165
173
|
def ready_workers(generation)
|
|
@@ -188,7 +196,7 @@ module Gritz
|
|
|
188
196
|
return
|
|
189
197
|
end
|
|
190
198
|
|
|
191
|
-
if @active.metadata[:bind].end_with?(":0")
|
|
199
|
+
if @active.metadata[:bind].end_with?(":0") && !@listener
|
|
192
200
|
@reexec = { state: "failed", error: "USR2 requires a fixed RPC port" }
|
|
193
201
|
@logger.warn(@reexec[:error])
|
|
194
202
|
return
|
|
@@ -371,6 +379,7 @@ module Gritz
|
|
|
371
379
|
@admin&.close
|
|
372
380
|
@observer&.close
|
|
373
381
|
@signals&.close
|
|
382
|
+
@listener&.close
|
|
374
383
|
if @written_pid && File.file?(@pid_path) && File.read(@pid_path).strip == @written_pid.to_s
|
|
375
384
|
File.unlink(@pid_path)
|
|
376
385
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Gritz
|
|
6
|
+
module Supervisor
|
|
7
|
+
# Creates a plain TCP listener without initializing a transport or scheduler.
|
|
8
|
+
# @api private
|
|
9
|
+
module Listener
|
|
10
|
+
def self.bind(address, reuseport: false)
|
|
11
|
+
host, _, port = address.rpartition(":")
|
|
12
|
+
info = Addrinfo.tcp(host.delete_prefix("[").delete_suffix("]"), Integer(port))
|
|
13
|
+
socket = Socket.new(info.afamily, Socket::SOCK_STREAM)
|
|
14
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, 1)
|
|
15
|
+
socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEPORT, 1) if reuseport
|
|
16
|
+
socket.bind(info)
|
|
17
|
+
socket.listen(Socket::SOMAXCONN)
|
|
18
|
+
socket
|
|
19
|
+
rescue StandardError
|
|
20
|
+
socket&.close
|
|
21
|
+
raise
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -10,12 +10,13 @@ module Gritz
|
|
|
10
10
|
SIGNALS = %w[TERM INT QUIT TTIN TTOU HUP CHLD USR1 USR2].freeze
|
|
11
11
|
attr_reader :workers
|
|
12
12
|
|
|
13
|
-
def initialize(config, logger: Logger.new($stdout), status_io: nil, owner_channel: nil)
|
|
13
|
+
def initialize(config, logger: Logger.new($stdout), status_io: nil, owner_channel: nil, listener: nil)
|
|
14
14
|
@config = config
|
|
15
15
|
@logger = logger
|
|
16
16
|
@workers = {}
|
|
17
17
|
@desired = config.workers
|
|
18
18
|
@owner_channel = owner_channel
|
|
19
|
+
@listener = listener
|
|
19
20
|
@reports = owner_channel || (StatusChannel.new(status_io) if status_io)
|
|
20
21
|
@metrics = Metrics::Aggregator.new
|
|
21
22
|
@forwarded = []
|
|
@@ -27,7 +28,8 @@ module Gritz
|
|
|
27
28
|
@config.validate_runtime!
|
|
28
29
|
raise ConfigurationError, "Supervisor requires workers > 0" unless @desired.positive?
|
|
29
30
|
|
|
30
|
-
require "gritz
|
|
31
|
+
require "gritz/#{@config.transport}"
|
|
32
|
+
@listener ||= Listener.bind(@config.bind) if @config.listener_strategy == :inherited_fd
|
|
31
33
|
@signals = SignalQueue.new(signals: SIGNALS)
|
|
32
34
|
unless @owner_channel
|
|
33
35
|
@admin = AdminServer.new(bind: @config.admin_bind, status: -> { status }, ready: -> { ready? },
|
|
@@ -36,7 +38,7 @@ module Gritz
|
|
|
36
38
|
@guard = ForkGuard.activate(mode: @config.fork_mode == :clean ? @config.fork_guard : :off, logger: @logger)
|
|
37
39
|
@config.preload! if @config.preload_app?
|
|
38
40
|
Process.warmup if @config.preload_app? && Process.respond_to?(:warmup)
|
|
39
|
-
if @config.workers > 1 && !RUBY_PLATFORM.include?("linux")
|
|
41
|
+
if @config.transport == :native && @config.workers > 1 && !RUBY_PLATFORM.include?("linux")
|
|
40
42
|
@logger.warn("Multiple native workers require Linux for SO_REUSEPORT load balancing; use workers 0 on macOS")
|
|
41
43
|
end
|
|
42
44
|
maintain_worker_count
|
|
@@ -118,7 +120,7 @@ module Gritz
|
|
|
118
120
|
@admin&.close
|
|
119
121
|
@workers.each_value(&:close)
|
|
120
122
|
Transport::Native.postfork_child if experimental
|
|
121
|
-
exit_status = Worker::Runner.new(index: index, status_io: writer, config: @config, logger: @logger).run
|
|
123
|
+
exit_status = Worker::Runner.new(index: index, status_io: writer, config: @config, logger: @logger, listener: @listener).run
|
|
122
124
|
rescue StandardError, LoadError, SyntaxError, SystemExit => e
|
|
123
125
|
@logger.error("Worker #{index} failed: #{e.full_message}")
|
|
124
126
|
exit_status = 1
|
|
@@ -242,7 +244,7 @@ module Gritz
|
|
|
242
244
|
when "TTIN"
|
|
243
245
|
if @replacement || !@replacement_queue.empty?
|
|
244
246
|
@logger.warn("Wait for phased restart before resizing workers")
|
|
245
|
-
elsif !@shutdown_at && @config.bind.end_with?(":0")
|
|
247
|
+
elsif !@shutdown_at && @config.bind.end_with?(":0") && !@listener
|
|
246
248
|
@logger.warn("Cannot add a reuseport worker with port 0; configure a fixed bind port")
|
|
247
249
|
elsif !@shutdown_at
|
|
248
250
|
@desired += 1
|
|
@@ -293,7 +295,7 @@ module Gritz
|
|
|
293
295
|
end
|
|
294
296
|
|
|
295
297
|
def fixed_port?
|
|
296
|
-
return true
|
|
298
|
+
return true if @listener || !@config.bind.end_with?(":0")
|
|
297
299
|
|
|
298
300
|
@logger.warn("Worker replacement requires a fixed bind port")
|
|
299
301
|
false
|
|
@@ -340,7 +342,7 @@ module Gritz
|
|
|
340
342
|
def check_recycle
|
|
341
343
|
return if @shutdown_at || @replacement || !@replacement_queue.empty? || @config.worker_recycle.empty?
|
|
342
344
|
return if @recycle_retry_at && now < @recycle_retry_at
|
|
343
|
-
return if @config.bind.end_with?(":0")
|
|
345
|
+
return if @config.bind.end_with?(":0") && !@listener
|
|
344
346
|
|
|
345
347
|
@workers.each_value do |handle|
|
|
346
348
|
next unless handle.state == "ready" && !handle.term_at
|
|
@@ -429,6 +431,7 @@ module Gritz
|
|
|
429
431
|
@workers.clear
|
|
430
432
|
@signals&.close
|
|
431
433
|
@admin&.close
|
|
434
|
+
@listener&.close
|
|
432
435
|
flush_forwarded
|
|
433
436
|
@reports&.close
|
|
434
437
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Gritz
|
|
6
|
+
module Testing
|
|
7
|
+
# Starts a single-process server for integration tests.
|
|
8
|
+
# @api public
|
|
9
|
+
class Server
|
|
10
|
+
attr_reader :port, :address, :transport
|
|
11
|
+
|
|
12
|
+
def self.start(config = nil, controllers: nil, middleware: nil, logger: Logger.new(File::NULL))
|
|
13
|
+
config ||= Configuration.new.tap do |settings|
|
|
14
|
+
settings.workers = 0
|
|
15
|
+
settings.bind = "127.0.0.1:0"
|
|
16
|
+
end
|
|
17
|
+
config.controllers = controllers if controllers
|
|
18
|
+
config.middleware = middleware if middleware
|
|
19
|
+
server = new(config, logger:).start
|
|
20
|
+
return server unless block_given?
|
|
21
|
+
|
|
22
|
+
begin
|
|
23
|
+
yield server
|
|
24
|
+
ensure
|
|
25
|
+
server.stop
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(config, logger:)
|
|
30
|
+
@config = config
|
|
31
|
+
@logger = logger
|
|
32
|
+
config.validate_single_process!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def start
|
|
36
|
+
raise ArgumentError, "Testing::Server is already started or stopped" if @started || @stopped
|
|
37
|
+
|
|
38
|
+
@started = true
|
|
39
|
+
begin
|
|
40
|
+
@config.preload! if @config.preload_app?
|
|
41
|
+
router = Router.new(controllers: @config.controllers, strict: @config.strict_routes, logger: @logger)
|
|
42
|
+
@lifecycle_started = true
|
|
43
|
+
@config.run_hooks(:on_worker_boot, 0)
|
|
44
|
+
@recorder = @config.metrics_recorder_factory&.call(worker: 0)
|
|
45
|
+
dispatcher = Dispatcher.new(router:, middleware: @config.middleware, logger: @logger, metrics: @recorder)
|
|
46
|
+
@transport = Transport.const_get(@config.transport.to_s.capitalize).new(config: @config, dispatcher:, logger: @logger)
|
|
47
|
+
@port = transport.bind
|
|
48
|
+
@address = @config.bind.sub(/:\d+\z/, ":#{port}")
|
|
49
|
+
transport.start
|
|
50
|
+
unless @config.health_checks.empty?
|
|
51
|
+
@health_stop = Queue.new
|
|
52
|
+
@health_thread = Thread.new do
|
|
53
|
+
transport.refresh_health until @health_stop.pop(timeout: @config.status_interval)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
self
|
|
57
|
+
rescue StandardError
|
|
58
|
+
stop
|
|
59
|
+
raise
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def stop
|
|
64
|
+
return if @stopped
|
|
65
|
+
|
|
66
|
+
@stopped = true
|
|
67
|
+
deadline = monotonic + @config.shutdown_timeout
|
|
68
|
+
begin
|
|
69
|
+
@health_stop&.push(true)
|
|
70
|
+
if @health_thread && !@health_thread.join([deadline - monotonic, 0].max)
|
|
71
|
+
@health_thread.kill.join
|
|
72
|
+
end
|
|
73
|
+
transport&.stop(deadline: Time.now + [deadline - monotonic, 0].max)
|
|
74
|
+
ensure
|
|
75
|
+
begin
|
|
76
|
+
@config.run_hooks(:on_worker_shutdown, 0) if @lifecycle_started
|
|
77
|
+
ensure
|
|
78
|
+
begin
|
|
79
|
+
@recorder&.observe_rejected(transport.stats.fetch(:rejected_total, 0)) if transport
|
|
80
|
+
@recorder&.observe_worker({ pid: Process.pid, index: 0, state: "stopped" })
|
|
81
|
+
ensure
|
|
82
|
+
@recorder&.close(timeout: [deadline - monotonic, 0].max)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rspec/core"
|
|
4
|
+
require "rspec/expectations"
|
|
5
|
+
require "grpc"
|
|
6
|
+
require "google/rpc/status_pb"
|
|
7
|
+
require "google/rpc/error_details_pb"
|
|
8
|
+
require "gritz/testing/server"
|
|
9
|
+
|
|
10
|
+
module Gritz
|
|
11
|
+
module Testing
|
|
12
|
+
# Shared wire tests using the official gRPC client as an independent oracle.
|
|
13
|
+
# Include with adapter:, service:, stub:, request:, reply: for a four-method fixture.
|
|
14
|
+
# Explicitly requiring this helper needs RSpec and grpc as test dependencies.
|
|
15
|
+
# @api public
|
|
16
|
+
module TransportContract
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
RSpec.shared_examples Gritz::Testing::TransportContract do |adapter:, service:, stub:, request:, reply:|
|
|
22
|
+
let(:contract_request) { request }
|
|
23
|
+
let(:contract_reply) { reply }
|
|
24
|
+
|
|
25
|
+
define_method(:contract_controller) do |&block|
|
|
26
|
+
response = reply
|
|
27
|
+
Class.new(Gritz::Controller) do
|
|
28
|
+
bind service
|
|
29
|
+
define_method(:say_hello) { response.new(message: self.request.message.name) }
|
|
30
|
+
define_method(:list_greetings) { 3.times { |i| stream.write(response.new(message: "#{self.request.message.name}:#{i}")) } }
|
|
31
|
+
define_method(:record_names) { response.new(count: self.request.each_message.count) }
|
|
32
|
+
define_method(:chat) { self.request.each_message { |msg| stream.write(response.new(message: msg.name)) } }
|
|
33
|
+
class_eval(&block) if block
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
define_method(:with_contract_server) do |controller = contract_controller, **settings, &block|
|
|
38
|
+
config = Gritz::Configuration.new
|
|
39
|
+
config.transport = adapter
|
|
40
|
+
config.listener_strategy = adapter == :async ? :inherited_fd : :reuseport
|
|
41
|
+
config.bind = "127.0.0.1:0"
|
|
42
|
+
settings.each { |key, value| config.public_send("#{key}=", value) }
|
|
43
|
+
Gritz::Testing::Server.start(config, controllers: [controller]) do |server|
|
|
44
|
+
client = stub.new(server.address, :this_channel_is_insecure)
|
|
45
|
+
block.call(client, server)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "preserves unary protobuf messages" do
|
|
50
|
+
with_contract_server { |client| expect(client.say_hello(contract_request.new(name: "Ruby")).message).to eq("Ruby") }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "preserves ordered server streaming messages" do
|
|
54
|
+
with_contract_server do |client|
|
|
55
|
+
expect(client.list_greetings(contract_request.new(name: "x")).map(&:message)).to eq(%w[x:0 x:1 x:2])
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it "reads client streams through half-close" do
|
|
60
|
+
with_contract_server do |client|
|
|
61
|
+
expect(client.record_names(%w[a b c].map { |name| contract_request.new(name:) }).count).to eq(3)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "replies to bidi input before the client half-closes" do
|
|
66
|
+
gate = Queue.new
|
|
67
|
+
requests = Enumerator.new do |output|
|
|
68
|
+
output << contract_request.new(name: "first")
|
|
69
|
+
gate.pop
|
|
70
|
+
output << contract_request.new(name: "second")
|
|
71
|
+
end
|
|
72
|
+
with_contract_server do |client|
|
|
73
|
+
responses = client.chat(requests, deadline: Time.now + 5)
|
|
74
|
+
expect(responses.next.message).to eq("first")
|
|
75
|
+
gate << true
|
|
76
|
+
expect(responses.next.message).to eq("second")
|
|
77
|
+
expect { responses.next }.to raise_error(StopIteration)
|
|
78
|
+
end
|
|
79
|
+
ensure
|
|
80
|
+
gate << true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "sends streaming output before the controller returns" do
|
|
84
|
+
gate = Queue.new
|
|
85
|
+
response = contract_reply
|
|
86
|
+
controller = contract_controller do
|
|
87
|
+
define_method(:list_greetings) do
|
|
88
|
+
stream.write(response.new(message: "first"))
|
|
89
|
+
gate.pop
|
|
90
|
+
stream.write(response.new(message: "second"))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
with_contract_server(controller) do |client|
|
|
94
|
+
responses = client.list_greetings(contract_request.new, deadline: Time.now + 5)
|
|
95
|
+
expect(responses.next.message).to eq("first")
|
|
96
|
+
gate << true
|
|
97
|
+
expect(responses.next.message).to eq("second")
|
|
98
|
+
expect { responses.next }.to raise_error(StopIteration)
|
|
99
|
+
end
|
|
100
|
+
ensure
|
|
101
|
+
gate << true
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it "passes binary metadata, deadline and peer, with separate headers and trailers" do
|
|
105
|
+
observations = Queue.new
|
|
106
|
+
response = contract_reply
|
|
107
|
+
controller = contract_controller do
|
|
108
|
+
define_method(:say_hello) do
|
|
109
|
+
observations << [context.metadata, context.deadline, context.peer, context.peer_identity]
|
|
110
|
+
context.call.send_initial_metadata("answer" => "initial", "reply-bin" => "\x00\xff".b, "repeat-bin" => ["a".b, "b".b])
|
|
111
|
+
context.call.trailing_metadata["answer"] = "trailing"
|
|
112
|
+
context.call.trailing_metadata["trailer-bin"] = "\x00\xfe".b
|
|
113
|
+
response.new(message: context.request_id)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
with_contract_server(controller) do |client|
|
|
117
|
+
deadline = Time.now + 5
|
|
118
|
+
operation = client.say_hello(contract_request.new, return_op: true, deadline:,
|
|
119
|
+
metadata: { "x-request-id" => "req-1", "request-bin" => "\x00\xfd".b, "repeat" => %w[a b] })
|
|
120
|
+
expect(operation.execute.message).to eq("req-1")
|
|
121
|
+
expect(operation.metadata).to include("answer" => "initial", "reply-bin" => "\x00\xff".b, "x-request-id" => "req-1")
|
|
122
|
+
expect(operation.trailing_metadata).to include("answer" => "trailing", "trailer-bin" => "\x00\xfe".b)
|
|
123
|
+
expect(operation.metadata["repeat-bin"]).to eq(%w[a b])
|
|
124
|
+
metadata, actual_deadline, peer, identity = observations.pop(timeout: 2)
|
|
125
|
+
expect(metadata).to include("request-bin" => "\x00\xfd".b)
|
|
126
|
+
expect(metadata["repeat"]).to eq(%w[a b])
|
|
127
|
+
expect(actual_deadline).to be_within(0.1).of(deadline)
|
|
128
|
+
expect(peer).to match(/ipv[46]:/)
|
|
129
|
+
expect(identity).to be_nil
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "encodes application status, metadata and rich protobuf details" do
|
|
134
|
+
controller = contract_controller do
|
|
135
|
+
def say_hello
|
|
136
|
+
fail!(:not_found, "missing", metadata: { "lookup" => "failed" },
|
|
137
|
+
details: [Google::Rpc::ResourceInfo.new(resource_type: "name", resource_name: "Ruby")])
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
with_contract_server(controller) do |client|
|
|
141
|
+
expect { client.say_hello(contract_request.new) }.to raise_error(GRPC::NotFound) do |error|
|
|
142
|
+
expect(error.details).to eq("missing")
|
|
143
|
+
expect(error.metadata).to include("lookup" => "failed")
|
|
144
|
+
status = Google::Rpc::Status.decode(error.metadata.fetch("grpc-status-details-bin"))
|
|
145
|
+
expect([status.code, status.message]).to eq([5, "missing"])
|
|
146
|
+
expect(status.details.first.unpack(Google::Rpc::ResourceInfo).resource_name).to eq("Ruby")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "preserves a non-OK status after streamed messages" do
|
|
152
|
+
response = contract_reply
|
|
153
|
+
controller = contract_controller do
|
|
154
|
+
define_method(:list_greetings) do
|
|
155
|
+
stream.write(response.new(message: "first"))
|
|
156
|
+
fail!(:permission_denied, "revoked", metadata: { "reason" => "revoked" })
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
with_contract_server(controller) do |client|
|
|
160
|
+
responses = client.list_greetings(contract_request.new)
|
|
161
|
+
expect(responses.next.message).to eq("first")
|
|
162
|
+
expect { responses.next }.to raise_error(GRPC::PermissionDenied) do |error|
|
|
163
|
+
expect(error.metadata).to include("reason" => "revoked")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it "hides internal exception messages" do
|
|
169
|
+
controller = contract_controller { def say_hello = raise("database secret") }
|
|
170
|
+
with_contract_server(controller) do |client|
|
|
171
|
+
expect { client.say_hello(contract_request.new) }.to raise_error(GRPC::Internal) do |error|
|
|
172
|
+
expect(error.details).not_to include("database secret")
|
|
173
|
+
expect(error.metadata).to have_key("error-id")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "rejects an unimplemented controller action" do
|
|
179
|
+
controller = Class.new(Gritz::Controller) { bind service }
|
|
180
|
+
with_contract_server(controller) do |client|
|
|
181
|
+
expect { client.say_hello(contract_request.new) }.to raise_error(GRPC::Unimplemented)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it "honors an active call deadline" do
|
|
186
|
+
controller = contract_controller { def say_hello = sleep(1) }
|
|
187
|
+
with_contract_server(controller) do |client|
|
|
188
|
+
expect { client.say_hello(contract_request.new, deadline: Time.now + 0.05) }.to raise_error(GRPC::DeadlineExceeded)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "returns CANCELLED when the client cancels an active call" do
|
|
193
|
+
entered = Queue.new
|
|
194
|
+
gate = Queue.new
|
|
195
|
+
controller = contract_controller do
|
|
196
|
+
define_method(:say_hello) do
|
|
197
|
+
entered << true
|
|
198
|
+
gate.pop
|
|
199
|
+
fail!(:cancelled, "cancelled")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
with_contract_server(controller) do |client|
|
|
203
|
+
operation = client.say_hello(contract_request.new, return_op: true, deadline: Time.now + 5)
|
|
204
|
+
caller = Thread.new do
|
|
205
|
+
operation.execute
|
|
206
|
+
rescue GRPC::BadStatus => e
|
|
207
|
+
e
|
|
208
|
+
end
|
|
209
|
+
expect(entered.pop(timeout: 2)).to be(true)
|
|
210
|
+
operation.cancel
|
|
211
|
+
expect(caller.join(2)).not_to be_nil
|
|
212
|
+
expect(caller.value).to be_a(GRPC::Cancelled)
|
|
213
|
+
gate << true
|
|
214
|
+
ensure
|
|
215
|
+
gate << true
|
|
216
|
+
operation&.cancel
|
|
217
|
+
caller&.join(5)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it "finishes an in-flight response during graceful shutdown" do
|
|
222
|
+
entered = Queue.new
|
|
223
|
+
gate = Queue.new
|
|
224
|
+
response = contract_reply
|
|
225
|
+
controller = contract_controller do
|
|
226
|
+
define_method(:say_hello) do
|
|
227
|
+
entered << true
|
|
228
|
+
gate.pop
|
|
229
|
+
response.new(message: "completed")
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
with_contract_server(controller) do |client, server|
|
|
233
|
+
caller = Thread.new { client.say_hello(contract_request.new, deadline: Time.now + 5) }
|
|
234
|
+
expect(entered.pop(timeout: 2)).to be(true)
|
|
235
|
+
stopper = Thread.new { server.transport.stop(deadline: Time.now + 3) }
|
|
236
|
+
sleep 0.02 # Let shutdown enter its drain phase while the application remains blocked.
|
|
237
|
+
gate << true
|
|
238
|
+
expect(caller.value.message).to eq("completed")
|
|
239
|
+
expect(stopper.join(4)).not_to be_nil
|
|
240
|
+
expect(server.transport.stats).to include(inflight: 0)
|
|
241
|
+
ensure
|
|
242
|
+
gate << true
|
|
243
|
+
caller&.join(5)
|
|
244
|
+
stopper&.join(5)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it "enforces the shutdown deadline on a blocked controller" do
|
|
249
|
+
entered = Queue.new
|
|
250
|
+
controller = contract_controller do
|
|
251
|
+
define_method(:say_hello) do
|
|
252
|
+
entered << true
|
|
253
|
+
Queue.new.pop
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
with_contract_server(controller) do |client, server|
|
|
257
|
+
caller = Thread.new do
|
|
258
|
+
client.say_hello(contract_request.new, deadline: Time.now + 5)
|
|
259
|
+
rescue GRPC::BadStatus => e
|
|
260
|
+
e
|
|
261
|
+
end
|
|
262
|
+
expect(entered.pop(timeout: 2)).to be(true)
|
|
263
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
264
|
+
server.transport.stop(deadline: Time.now + 0.05)
|
|
265
|
+
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 1
|
|
266
|
+
expect(caller.join(2)).not_to be_nil
|
|
267
|
+
expect(caller.value).to be_a(GRPC::BadStatus)
|
|
268
|
+
expect(server.transport.running?).to be(false)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
data/lib/gritz/worker/runner.rb
CHANGED
|
@@ -7,11 +7,12 @@ module Gritz
|
|
|
7
7
|
# Owns transport resources and heartbeats in one serving process.
|
|
8
8
|
# @api private
|
|
9
9
|
class Runner
|
|
10
|
-
def initialize(index:, config:, logger:, status_io: nil, owner_channel: nil)
|
|
10
|
+
def initialize(index:, config:, logger:, status_io: nil, owner_channel: nil, listener: nil)
|
|
11
11
|
@index = index
|
|
12
12
|
@config = config
|
|
13
13
|
@logger = logger
|
|
14
14
|
@owner_channel = owner_channel
|
|
15
|
+
@listener = listener
|
|
15
16
|
@status = owner_channel || (Supervisor::StatusChannel.new(status_io) if status_io)
|
|
16
17
|
@recorder = Metrics::Recorder.new
|
|
17
18
|
@metrics = Metrics::Aggregator.new
|
|
@@ -24,7 +25,7 @@ module Gritz
|
|
|
24
25
|
begin
|
|
25
26
|
@signals = Supervisor::SignalQueue.new(signals: %w[TERM INT QUIT HUP USR1 USR2])
|
|
26
27
|
report("booting")
|
|
27
|
-
require "gritz
|
|
28
|
+
require "gritz/#{@config.transport}"
|
|
28
29
|
@boot_started = true
|
|
29
30
|
@config.preload! unless @config.preload_app?
|
|
30
31
|
@config.run_hooks(:on_worker_boot, @index)
|
|
@@ -32,8 +33,8 @@ module Gritz
|
|
|
32
33
|
router = Router.new(controllers: @config.controllers, strict: @config.strict_routes, logger: @logger)
|
|
33
34
|
dispatcher = Dispatcher.new(router:, middleware: @config.middleware, logger: @logger, metrics: @recorder,
|
|
34
35
|
worker: @index, log_format: @config.log_format, log_redact: @config.log_redact)
|
|
35
|
-
@adapter = Transport
|
|
36
|
-
@port = @adapter.bind(@config.bind)
|
|
36
|
+
@adapter = Transport.const_get(@config.transport.to_s.capitalize).new(config: @config, dispatcher:, logger: @logger)
|
|
37
|
+
@port = @adapter.bind(@listener || @config.bind)
|
|
37
38
|
@adapter.start
|
|
38
39
|
unless @status
|
|
39
40
|
@admin = Supervisor::AdminServer.new(bind: @config.admin_bind, status: -> { snapshot }, ready: -> { ready? },
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gritz-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -152,6 +152,7 @@ files:
|
|
|
152
152
|
- lib/gritz/router.rb
|
|
153
153
|
- lib/gritz/supervisor/admin_server.rb
|
|
154
154
|
- lib/gritz/supervisor/launcher.rb
|
|
155
|
+
- lib/gritz/supervisor/listener.rb
|
|
155
156
|
- lib/gritz/supervisor/master.rb
|
|
156
157
|
- lib/gritz/supervisor/signal_queue.rb
|
|
157
158
|
- lib/gritz/supervisor/status_channel.rb
|
|
@@ -161,6 +162,8 @@ files:
|
|
|
161
162
|
- lib/gritz/testing/minitest.rb
|
|
162
163
|
- lib/gritz/testing/rpc_helper.rb
|
|
163
164
|
- lib/gritz/testing/rspec.rb
|
|
165
|
+
- lib/gritz/testing/server.rb
|
|
166
|
+
- lib/gritz/testing/transport_contract.rb
|
|
164
167
|
- lib/gritz/worker/runner.rb
|
|
165
168
|
homepage: https://github.com/gritzrpc/gritz-core
|
|
166
169
|
licenses:
|