gritz-core 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +5 -1
- data/exe/gritz-migrate-gruf +25 -0
- data/lib/gritz/cli.rb +24 -5
- data/lib/gritz/compat/gruf/config_converter.rb +159 -0
- data/lib/gritz/compat/gruf.rb +201 -0
- data/lib/gritz/configuration.rb +10 -4
- data/lib/gritz/controller.rb +8 -1
- 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/admin_server.rb +2 -1
- data/lib/gritz/supervisor/launcher.rb +12 -3
- data/lib/gritz/supervisor/listener.rb +25 -0
- data/lib/gritz/supervisor/master.rb +22 -8
- data/lib/gritz/supervisor/signal_queue.rb +2 -1
- data/lib/gritz/supervisor/status_channel.rb +4 -2
- data/lib/gritz/testing/server.rb +93 -0
- data/lib/gritz/testing/transport_contract.rb +271 -0
- data/lib/gritz/worker/runner.rb +6 -5
- metadata +10 -3
|
@@ -49,7 +49,7 @@ module Gritz
|
|
|
49
49
|
end
|
|
50
50
|
update_lifecycle
|
|
51
51
|
@admin&.poll
|
|
52
|
-
@observer&.write
|
|
52
|
+
@observer&.write { status }
|
|
53
53
|
@observer&.flush
|
|
54
54
|
break if @stopping && @generations.empty?
|
|
55
55
|
|
|
@@ -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
|
|
@@ -50,7 +52,10 @@ module Gritz
|
|
|
50
52
|
advance_replacement
|
|
51
53
|
maintain_worker_count unless @shutdown_at
|
|
52
54
|
flush_forwarded
|
|
53
|
-
@reports
|
|
55
|
+
if @reports && @forwarded.empty? && (!@next_report_at || now >= @next_report_at || @shutdown_at) &&
|
|
56
|
+
@reports.write { @owner_channel ? status.merge(type: "status") : status }
|
|
57
|
+
@next_report_at = now + @config.status_interval
|
|
58
|
+
end
|
|
54
59
|
@admin&.poll
|
|
55
60
|
if @owner_channel&.closed?
|
|
56
61
|
@exit_status = 1
|
|
@@ -115,7 +120,7 @@ module Gritz
|
|
|
115
120
|
@admin&.close
|
|
116
121
|
@workers.each_value(&:close)
|
|
117
122
|
Transport::Native.postfork_child if experimental
|
|
118
|
-
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
|
|
119
124
|
rescue StandardError, LoadError, SyntaxError, SystemExit => e
|
|
120
125
|
@logger.error("Worker #{index} failed: #{e.full_message}")
|
|
121
126
|
exit_status = 1
|
|
@@ -132,6 +137,7 @@ module Gritz
|
|
|
132
137
|
handle = WorkerHandle.new(pid: pid, index: index, status_io: reader)
|
|
133
138
|
handle.recycle_factor = 1.0 + (rand * @config.worker_recycle.fetch(:jitter, 0.0))
|
|
134
139
|
@workers[pid] = handle
|
|
140
|
+
@next_report_at = nil
|
|
135
141
|
@logger.info("Worker #{index} spawned pid=#{pid}")
|
|
136
142
|
handle
|
|
137
143
|
rescue StandardError
|
|
@@ -159,7 +165,9 @@ module Gritz
|
|
|
159
165
|
end
|
|
160
166
|
next
|
|
161
167
|
end
|
|
168
|
+
changed = handle.state != message[:state] || (message.key?(:healthy) && handle.stats[:healthy] != message[:healthy])
|
|
162
169
|
handle.update(message, now: now)
|
|
170
|
+
@next_report_at = nil if changed
|
|
163
171
|
if message[:state] == "failed" && !@ever_ready
|
|
164
172
|
@exit_status = 1
|
|
165
173
|
begin_shutdown
|
|
@@ -207,6 +215,7 @@ module Gritz
|
|
|
207
215
|
break if handle.channel.closed?
|
|
208
216
|
end
|
|
209
217
|
@workers.delete(pid)
|
|
218
|
+
@next_report_at = nil
|
|
210
219
|
@metrics.forget(handle)
|
|
211
220
|
handle.close
|
|
212
221
|
child_status = result.last
|
|
@@ -223,17 +232,19 @@ module Gritz
|
|
|
223
232
|
break unless @forwarded.empty?
|
|
224
233
|
rescue Errno::ECHILD
|
|
225
234
|
@workers.delete(pid)&.close
|
|
235
|
+
@next_report_at = nil
|
|
226
236
|
end
|
|
227
237
|
end
|
|
228
238
|
|
|
229
239
|
def handle_signal(signal)
|
|
240
|
+
@next_report_at = nil
|
|
230
241
|
case signal
|
|
231
242
|
when "TERM", "INT" then begin_shutdown
|
|
232
243
|
when "QUIT" then begin_shutdown(immediate: true)
|
|
233
244
|
when "TTIN"
|
|
234
245
|
if @replacement || !@replacement_queue.empty?
|
|
235
246
|
@logger.warn("Wait for phased restart before resizing workers")
|
|
236
|
-
elsif !@shutdown_at && @config.bind.end_with?(":0")
|
|
247
|
+
elsif !@shutdown_at && @config.bind.end_with?(":0") && !@listener
|
|
237
248
|
@logger.warn("Cannot add a reuseport worker with port 0; configure a fixed bind port")
|
|
238
249
|
elsif !@shutdown_at
|
|
239
250
|
@desired += 1
|
|
@@ -284,7 +295,7 @@ module Gritz
|
|
|
284
295
|
end
|
|
285
296
|
|
|
286
297
|
def fixed_port?
|
|
287
|
-
return true
|
|
298
|
+
return true if @listener || !@config.bind.end_with?(":0")
|
|
288
299
|
|
|
289
300
|
@logger.warn("Worker replacement requires a fixed bind port")
|
|
290
301
|
false
|
|
@@ -301,6 +312,7 @@ module Gritz
|
|
|
301
312
|
kill(fresh) if fresh
|
|
302
313
|
@logger.warn("Replacement failed; keeping previous workers")
|
|
303
314
|
@replacement = nil
|
|
315
|
+
@next_report_at = nil
|
|
304
316
|
@replacement_queue.clear
|
|
305
317
|
@recycle_retry_at = now + @config.worker_boot_timeout
|
|
306
318
|
return true
|
|
@@ -314,6 +326,7 @@ module Gritz
|
|
|
314
326
|
return if old || !@replacement[:retired]
|
|
315
327
|
|
|
316
328
|
@replacement = nil
|
|
329
|
+
@next_report_at = nil
|
|
317
330
|
end
|
|
318
331
|
while (entry = @replacement_queue.shift)
|
|
319
332
|
old_pid, reason = entry
|
|
@@ -329,7 +342,7 @@ module Gritz
|
|
|
329
342
|
def check_recycle
|
|
330
343
|
return if @shutdown_at || @replacement || !@replacement_queue.empty? || @config.worker_recycle.empty?
|
|
331
344
|
return if @recycle_retry_at && now < @recycle_retry_at
|
|
332
|
-
return if @config.bind.end_with?(":0")
|
|
345
|
+
return if @config.bind.end_with?(":0") && !@listener
|
|
333
346
|
|
|
334
347
|
@workers.each_value do |handle|
|
|
335
348
|
next unless handle.state == "ready" && !handle.term_at
|
|
@@ -418,6 +431,7 @@ module Gritz
|
|
|
418
431
|
@workers.clear
|
|
419
432
|
@signals&.close
|
|
420
433
|
@admin&.close
|
|
434
|
+
@listener&.close
|
|
421
435
|
flush_forwarded
|
|
422
436
|
@reports&.close
|
|
423
437
|
end
|
|
@@ -10,6 +10,7 @@ module Gritz
|
|
|
10
10
|
def initialize(signals:)
|
|
11
11
|
@io, @writer = IO.pipe
|
|
12
12
|
@pending = []
|
|
13
|
+
@read_buffer = +""
|
|
13
14
|
@previous = {}
|
|
14
15
|
signals.each do |name|
|
|
15
16
|
@previous[name] = Signal.trap(name) do
|
|
@@ -26,7 +27,7 @@ module Gritz
|
|
|
26
27
|
|
|
27
28
|
def drain
|
|
28
29
|
loop do
|
|
29
|
-
break unless @io.read_nonblock(4096, exception: false).is_a?(String)
|
|
30
|
+
break unless @io.read_nonblock(4096, @read_buffer, exception: false).is_a?(String)
|
|
30
31
|
end
|
|
31
32
|
# Swap instead of clearing: a signal arriving here belongs to the next drain.
|
|
32
33
|
pending = @pending
|
|
@@ -16,14 +16,16 @@ module Gritz
|
|
|
16
16
|
def initialize(io)
|
|
17
17
|
@io = io
|
|
18
18
|
@buffer = +""
|
|
19
|
+
@read_buffer = +""
|
|
19
20
|
@pending = +""
|
|
20
21
|
@discarding = false
|
|
21
22
|
@closed = io.closed?
|
|
22
23
|
end
|
|
23
24
|
|
|
24
|
-
def write(status)
|
|
25
|
+
def write(status = nil)
|
|
25
26
|
return false if closed? || flush_pending.positive?
|
|
26
27
|
|
|
28
|
+
status = yield if block_given?
|
|
27
29
|
line = "#{JSON.generate(status)}\n"
|
|
28
30
|
return false if line.bytesize > MAX_LINE_BYTES
|
|
29
31
|
|
|
@@ -54,7 +56,7 @@ module Gritz
|
|
|
54
56
|
|
|
55
57
|
remaining = max_bytes
|
|
56
58
|
while remaining.positive?
|
|
57
|
-
chunk = @io.read_nonblock([4096, remaining].min, exception: false)
|
|
59
|
+
chunk = @io.read_nonblock([4096, remaining].min, @read_buffer, exception: false)
|
|
58
60
|
break if chunk == :wait_readable
|
|
59
61
|
|
|
60
62
|
if chunk.nil?
|
|
@@ -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? },
|
|
@@ -95,7 +96,7 @@ module Gritz
|
|
|
95
96
|
if @pending_reexec && @owner_channel.write(type: "reexec", pid: Process.pid)
|
|
96
97
|
@pending_reexec = false
|
|
97
98
|
end
|
|
98
|
-
publish_metrics
|
|
99
|
+
publish_metrics if @pending_delta || now >= next_status
|
|
99
100
|
@admin&.poll
|
|
100
101
|
if now >= next_status
|
|
101
102
|
report(@draining ? "draining" : "ready")
|