gritz-core 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ed1b7320b17241d7bf44746aff7108965c5bd4ef5c5acfeb26bade59786c5775
4
- data.tar.gz: 062fa21a3446621c20990013a0bb7a6dd23c38803d5e58597902f83079dba21c
3
+ metadata.gz: 365ed02137d42c5182e6f09a10544c189d7fe0d70fc0faffd2864b752e13e40c
4
+ data.tar.gz: 797d64d766c36257ef700d7647045d317c4027ed4b7295f959f54fec2139849e
5
5
  SHA512:
6
- metadata.gz: 0f4702d111e9e72546b8a0a219703e355fba71a7ecbcd064d7c2fca60461789ee8c5c03dc98d050d843302f71506e9a9169bc88c9c8d73671231ff8ca8ca6035
7
- data.tar.gz: 3fc17735fed2dc9068e54aae5853d6595d344458cb535eaa3be2ec8a63d2cd1c5d1f546175183639b3fa6f3309506bfa8d3041b91211456982023b187b6cb1a7
6
+ metadata.gz: 8cdc413c2a357fb5e98ce5c397ae5e1b69d136f89752d479d4960367fc0a2cf6d99dabeebede608aa2f7c8f948ee542640ff4f269323a7aa203d5ef07c027296
7
+ data.tar.gz: 3da9357cc46ca9f495f997803929d489e3ca691074cdad0b1e33f49457bcbc35d58ad37b3331b754b08967a3ff5376dfd60f6a02943a8e6e3e8fac34f78a35a7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1
4
+
5
+ - Avoid formatting suppressed RPC completion logs, preserving responses, status mapping and metrics when INFO logging is disabled.
6
+
7
+ ## 0.6.0
8
+
9
+ - Run Async workers through the same supervisor, CLI and real-server test helper as Native workers.
10
+ - Retain one inherited listener across forked workers and fresh master replacement, including ephemeral ports and recycling.
11
+ - Share adapter wire tests through `Gritz::Testing::TransportContract` without loading optional test dependencies in production.
12
+ - Preserve Async deadline errors through the standard exception middleware.
13
+
3
14
  ## 0.5.0
4
15
 
5
16
  - 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 Fiber adapter `gritz-async` is planned.
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
- [RbConfig.ruby, "-I", $LOAD_PATH.join(File::PATH_SEPARATOR), "-rgritz/core", "-e", CHILD_BOOTSTRAP, "--", *argv]
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
- require "gritz/native" if command != "routes"
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
@@ -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
- raise ConfigurationError, "This release supports transport :native" unless transport == :native
121
- raise ConfigurationError, "This release supports listener_strategy :reuseport" unless listener_strategy == :reuseport
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
- raise ConfigurationError, "worker_recycle requires a fixed bind port" if bind.end_with?(":0")
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?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gritz
4
4
  module Core
5
- VERSION = "0.5.0"
5
+ VERSION = "0.6.1"
6
6
  end
7
7
  end
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"
@@ -22,22 +22,23 @@ module Gritz
22
22
  code = :internal
23
23
  raise
24
24
  ensure
25
- duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
26
- fields = { request_id: context.request_id, service: context.method.service,
27
- method: context.method.name, code: code, duration_ms: duration.round(3),
28
- peer: context.peer, worker: context.worker, pid: Process.pid, bytes_in: context.bytes_in, bytes_out: context.bytes_out }
29
- fields.merge!(context.store.fetch(:gritz_error, {}))
30
- fields = prepare(fields, context.log_redact)
31
- message = if context.log_format == :logfmt
32
- fields.map do |key, value|
33
- value = value.to_s if value.is_a?(Symbol)
34
- value = JSON.generate(value) if value.is_a?(Hash) || value.is_a?(Array)
35
- "#{key}=#{JSON.generate(value)}"
36
- end.join(" ")
37
- else
38
- JSON.generate(fields)
39
- end
40
- context.logger.info(message)
25
+ context.logger.info do
26
+ duration = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
27
+ fields = { request_id: context.request_id, service: context.method.service,
28
+ method: context.method.name, code: code, duration_ms: duration.round(3),
29
+ peer: context.peer, worker: context.worker, pid: Process.pid, bytes_in: context.bytes_in, bytes_out: context.bytes_out }
30
+ fields.merge!(context.store.fetch(:gritz_error, {}))
31
+ fields = prepare(fields, context.log_redact)
32
+ if context.log_format == :logfmt
33
+ fields.map do |key, value|
34
+ value = value.to_s if value.is_a?(Symbol)
35
+ value = JSON.generate(value) if value.is_a?(Hash) || value.is_a?(Array)
36
+ "#{key}=#{JSON.generate(value)}"
37
+ end.join(" ")
38
+ else
39
+ JSON.generate(fields)
40
+ end
41
+ end
41
42
  end
42
43
 
43
44
  private
@@ -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/native"
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 unless @config.bind.end_with?(":0")
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
@@ -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/native"
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::Native.new(config: @config, dispatcher:, logger: @logger)
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.5.0
4
+ version: 0.6.1
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: