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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 600c0b33c1d7f7008866ee20d789504559715612886cd3f8dc33413e5192cb36
4
- data.tar.gz: c3797690d572a4d29f5275946b16f84b5717fb19e4f31fd6a20217ece840eb92
3
+ metadata.gz: fc43859887cac5ffa9e137f2cab8911a4a6ef79ed4c522380d55869446c360a9
4
+ data.tar.gz: adfbe678ac9b1af0f507cad8fb69c16bfea7acbe4a422bacfce1e6b90747f34b
5
5
  SHA512:
6
- metadata.gz: f2858b3d8e39c28d309a3acce2255fcc42ec4aba071b2b79735025a840973ffe0016183204eb7e87c67b1a14f11fef48ffa9e6560234cfd5da0866ccd195f68f
7
- data.tar.gz: 149a97e113197dc7691152d5aa0f7e246adb55cc0bb90a699b122078ea3272cd32787461808b78d7379804c83a487866f503d8eea7f135a5c356c011e40a2209
6
+ metadata.gz: 0c1d9ef947490cfa7c5832cca9d1c20f0c7e093798e56c4d3b0b261c80279ae1a55f2b747ea2f9c42af99c126ac91a3c55098d18a186d61a5ab362e1caad0b1d
7
+ data.tar.gz: 6e859a121f10896fb4e38f8cbf669e33ce504637026aa2269d338f003a64f02190b8299b59e145a4909bd5cf6cbd0d62fbab3fac630952154e880cd900cf0014
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
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
+
10
+ ## 0.5.0
11
+
12
+ - Migrate Gruf controllers and server interceptors through `Gritz::Compat::Gruf`, with a configuration conversion tool.
13
+ - Configure optional gRPC Reflection through the `reflection` setting.
14
+ - Avoid unnecessary supervisor snapshot allocation when status consumers are paused.
15
+ - Publish worker metrics at `status_interval` to reduce supervisor overhead; retain retries and final shutdown totals.
16
+ - Eagerly load the transport-independent framework before application warmup so forked workers can share its memory.
17
+ - Send supervisor snapshots at `status_interval`, with immediate lifecycle and health changes, to reduce parent-process allocation.
18
+ - Reuse signal, status and admin read buffers to avoid allocating memory on every idle poll.
19
+
3
20
  ## 0.4.0
4
21
 
5
22
  - Define lazy, fork-safe clients with process-local shared connections, per-call middleware, deadline propagation and selected request headers.
data/README.md CHANGED
@@ -6,14 +6,18 @@ 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.
16
18
 
19
+ The optional `Gritz::Compat::Gruf` module adapts Gruf controllers, returned response streams and server interceptors. `gritz-migrate-gruf` converts supported static configuration without executing the source file. See the [Gruf migration guide](docs/guides/migrating-from-gruf.md). Add [gritz-rails](https://github.com/gritzrpc/gritz-rails) for Rails execution, generators and development reloading.
20
+
17
21
  ## Development
18
22
 
19
23
  ```sh
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "gritz/core"
5
+ require "optparse"
6
+
7
+ begin
8
+ destination = nil
9
+ parser = OptionParser.new do |options|
10
+ options.banner = "Usage: gritz-migrate-gruf SOURCE [--output config/gritz.rb]"
11
+ options.on("--output PATH", "Write a new file; existing files are never overwritten") { |path| destination = path }
12
+ end
13
+ parser.parse!
14
+ raise OptionParser::MissingArgument, "SOURCE" unless ARGV.size == 1
15
+
16
+ output = Gritz::Compat::Gruf::ConfigConverter.new.convert(File.read(ARGV.first))
17
+ if destination
18
+ File.open(destination, File::WRONLY | File::CREAT | File::EXCL) { |file| file.write(output) }
19
+ else
20
+ $stdout.write(output)
21
+ end
22
+ rescue Gritz::ConfigurationError, OptionParser::ParseError, SystemCallError => e
23
+ warn "gritz-migrate-gruf: #{e.message}"
24
+ exit 1
25
+ end
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
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module Gritz
6
+ module Compat
7
+ module Gruf
8
+ # Converts a deliberately small, literal-only subset of Gruf.configure.
9
+ # @api public
10
+ class ConfigConverter
11
+ ARGUMENTS = {
12
+ "grpc.max_receive_message_length" => [:max_receive_message_size, 1],
13
+ "grpc.max_send_message_length" => [:max_send_message_size, 1],
14
+ "grpc.max_metadata_size" => [:max_metadata_size, 1],
15
+ "grpc.max_connection_age_ms" => [:max_connection_age, 1000.0],
16
+ "grpc.max_connection_age_grace_ms" => [:max_connection_age_grace, 1000.0],
17
+ "grpc.keepalive_time_ms" => [:keepalive_time, 1000.0]
18
+ }.freeze
19
+
20
+ # @param source [String] Ruby source containing one static Gruf.configure block
21
+ # @return [String] Gritz configuration requiring manual review before use
22
+ # @raise [Gritz::ConfigurationError] when a setting or expression cannot be converted
23
+ # @api public
24
+ def convert(source)
25
+ settings = assignments(source)
26
+ output = { workers: 0 }
27
+ settings.each do |name, value|
28
+ case name
29
+ when "server_binding_url" then output[:bind] = value
30
+ when "rpc_server_options" then server_options(value, output)
31
+ when "use_ssl", "ssl_crt_file", "ssl_key_file" then next
32
+ when "default_client_host", "default_channel_credentials"
33
+ unsupported("#{name}: configure Gritz::Client.define manually")
34
+ else unsupported("#{name}: manual migration required; see migrating-from-gruf.md")
35
+ end
36
+ end
37
+ config = Gritz::Configuration.new
38
+ output.each { |name, value| config.public_send("#{name}=", value) }
39
+ config.validate!
40
+ tls = tls_options(settings)
41
+ output[:tls] = tls unless tls.empty?
42
+ ["# Review controller registration, interceptors and fork safety before starting.",
43
+ 'require "gritz/compat/gruf"', "", *output.map { |name, value| "#{name}(#{value.inspect})" }, "",
44
+ "# register_controller YourMigratedController", ""].join("\n")
45
+ end
46
+
47
+ private
48
+
49
+ def assignments(source)
50
+ tree = Ripper.sexp(source)
51
+ unsupported("invalid Ruby syntax; manual correction required") unless tree
52
+ statements = tree[1].reject { |node| node[0] == :void_stmt }
53
+ unsupported("expected one Gruf.configure block; other executable code needs manual migration") unless statements.size == 1
54
+ node = statements.first
55
+ call = node[1]
56
+ block = node[2]
57
+ unless node[0] == :method_add_block && call&.dig(0) == :call && call.dig(3, 1) == "configure" &&
58
+ %i[var_ref top_const_ref].include?(call.dig(1, 0)) && call.dig(1, 1, 1) == "Gruf"
59
+ unsupported("expected Gruf.configure; manual migration required")
60
+ end
61
+ params = block&.dig(1, 1)
62
+ unless %i[do_block brace_block].include?(block&.dig(0)) && params&.dig(0) == :params &&
63
+ params[1]&.size == 1 && params[2..].all?(&:nil?) && block.dig(1, 2) == false
64
+ unsupported("configure needs one block variable; manual migration required")
65
+ end
66
+ variable = params[1][0][1]
67
+ body = block[2]
68
+ if block[0] == :do_block
69
+ unsupported("rescue/ensure needs manual migration") unless body[2..].all?(&:nil?)
70
+ body = body[1]
71
+ end
72
+ body.each_with_object({}) do |statement, values|
73
+ next if statement[0] == :void_stmt
74
+
75
+ field = statement[1]
76
+ unless statement[0] == :assign && field&.dig(0) == :field && field.dig(1, 0) == :var_ref && field.dig(1, 1, 1) == variable
77
+ unsupported("only literal assignments are supported; interceptors and other code require manual migration")
78
+ end
79
+ name = field[3][1]
80
+ unsupported("duplicate setting #{name}; manual migration required") if values.key?(name)
81
+ values[name] = literal(statement[2])
82
+ end
83
+ end
84
+
85
+ def literal(node)
86
+ case node[0]
87
+ when :@int then Integer(node[1].delete("_"), 0)
88
+ when :@float then Float(node[1].delete("_"))
89
+ when :string_literal
90
+ parts = node[1][1..]
91
+ unsupported("strings must be literals without interpolation or escapes") unless parts.all? { |part|
92
+ part[0] == :@tstring_content && !part[1].include?("\\")
93
+ }
94
+ parts.map { |part| part[1] }.join
95
+ when :symbol_literal
96
+ token = node.dig(1, 1)
97
+ unsupported("symbol must be a static literal") unless %i[@ident @op @const].include?(token&.dig(0))
98
+ token[1].to_sym
99
+ when :@label then node[1].delete_suffix(":").to_sym
100
+ when :var_ref
101
+ return { "true" => true, "false" => false, "nil" => nil }.fetch(node[1][1]) if node[1][0] == :@kw
102
+
103
+ unsupported("dynamic values need manual migration; use static literals")
104
+ when :unary
105
+ unsupported("only negative numeric literals are supported") unless node[1] == :-@ && %i[@int @float].include?(node.dig(2, 0))
106
+ -literal(node[2])
107
+ when :hash
108
+ pairs = node.dig(1, 1) || []
109
+ pairs.each_with_object({}) do |pair, values|
110
+ unsupported("hash splats need manual migration; use literal entries") unless pair[0] == :assoc_new
111
+ key = literal(pair[1])
112
+ unsupported("duplicate hash key #{key}; manual migration required") if values.key?(key)
113
+ values[key] = literal(pair[2])
114
+ end
115
+ else unsupported("dynamic values need manual migration; only static literals are supported")
116
+ end
117
+ rescue ArgumentError, KeyError
118
+ unsupported("invalid literal; manual migration required")
119
+ end
120
+
121
+ def server_options(options, output)
122
+ unsupported("rpc_server_options must be a literal Hash") unless options.is_a?(Hash)
123
+ options.each do |name, value|
124
+ case name
125
+ when :pool_size then output[:threads] = value
126
+ when :max_waiting_requests then output[:max_waiting_requests] = value
127
+ when :server_args
128
+ unsupported("server_args must be a literal Hash") unless value.is_a?(Hash)
129
+ value.each do |key, argument|
130
+ conversion = ARGUMENTS[key]
131
+ unsupported("#{key}: manual C-core argument migration required") unless conversion
132
+ unsupported("#{key} must be a numeric literal") unless argument.is_a?(Numeric)
133
+ setting, divisor = conversion
134
+ output[setting] = argument / divisor
135
+ end
136
+ else unsupported("#{name}: manual rpc_server_options migration required")
137
+ end
138
+ end
139
+ end
140
+
141
+ def tls_options(settings)
142
+ enabled = settings.fetch("use_ssl", false)
143
+ unsupported("use_ssl must be a boolean literal") unless [true, false].include?(enabled)
144
+ paths = %w[ssl_crt_file ssl_key_file]
145
+ unless enabled
146
+ paths.each { |key| unsupported("#{key} requires use_ssl=true; manual review required") if settings.key?(key) }
147
+ return {}
148
+ end
149
+ unless paths.all? { |key| settings[key].is_a?(String) && !settings[key].empty? }
150
+ unsupported("use_ssl=true requires ssl_crt_file and ssl_key_file; manual review required")
151
+ end
152
+ { cert: settings.fetch("ssl_crt_file"), key: settings.fetch("ssl_key_file") }
153
+ end
154
+
155
+ def unsupported(message) = raise Gritz::ConfigurationError, message
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gritz/core"
4
+ require "json"
5
+
6
+ module Gritz
7
+ module Compat
8
+ # Selected Gruf 2.22 controller/interceptor APIs, without loading Gruf or grpc.
9
+ # @api public
10
+ module Gruf
11
+ REQUEST_KEY = Object.new.freeze
12
+
13
+ # @api private
14
+ def self.request_for(context)
15
+ context.store[REQUEST_KEY] ||= Request.new(context)
16
+ end
17
+
18
+ # Wraps a Gruf-style interceptor as a Gritz server middleware.
19
+ # @param klass [Class] interceptor implementing #call and yielding to the action
20
+ # @return [Class] middleware accepted by Middleware::Stack#use
21
+ # @api public
22
+ def self.interceptor(klass)
23
+ Class.new(InterceptorAdapter) do
24
+ define_method(:initialize) { |app, **options| super(app, klass, options) }
25
+ end
26
+ end
27
+
28
+ class RequestContext < Hash
29
+ def [](key) = super(convert_key(key))
30
+
31
+ def []=(key, value)
32
+ super(convert_key(key), value)
33
+ end
34
+
35
+ def fetch(key, ...) = super(convert_key(key), ...)
36
+ def key?(key) = super(convert_key(key))
37
+ def merge!(other, &) = super(other.transform_keys { |key| convert_key(key) }, &)
38
+ alias update merge!
39
+
40
+ private
41
+
42
+ def convert_key(key) = key.is_a?(Symbol) ? key.to_s : key
43
+ end
44
+
45
+ class ActiveCall
46
+ def initialize(context) = @context = context
47
+ def metadata = @context.metadata
48
+ def deadline = @context.deadline
49
+ def peer = @context.peer
50
+ def cancelled? = @context.cancelled?
51
+ def output_metadata = @context.call.trailing_metadata
52
+ end
53
+
54
+ class Request
55
+ attr_reader :context, :active_call, :error
56
+
57
+ def initialize(context)
58
+ @rpc_context = context
59
+ @original = Gritz::Controller::Request.for(context)
60
+ @context = RequestContext.new
61
+ @active_call = ActiveCall.new(context)
62
+ @error = Error.new
63
+ end
64
+
65
+ def service = @rpc_context.method.service_class
66
+ def method_key = @rpc_context.method.action
67
+ def request_class = @rpc_context.method.input_type
68
+ def response_class = @rpc_context.method.output_type
69
+ def metadata = @rpc_context.metadata
70
+ def type = self
71
+ def client_streamer? = @rpc_context.method.kind == :client_streaming
72
+ def server_streamer? = @rpc_context.method.kind == :server_streaming
73
+ def bidi_streamer? = @rpc_context.method.kind == :bidi
74
+ def request_response? = @rpc_context.method.kind == :unary
75
+
76
+ def service_key
77
+ name = service.name || @rpc_context.method.service
78
+ name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2')
79
+ .gsub("::", ".").downcase.delete_suffix(".service")
80
+ end
81
+
82
+ def method_name = "#{service_key}.#{method_key}"
83
+
84
+ def message
85
+ return @original.message unless @rpc_context.method.client_streaming?
86
+
87
+ @message ||= client_streamer? ? proc { |&block| @original.each_message(&block) } : @original.each_message
88
+ end
89
+
90
+ def messages(&block)
91
+ return [message] unless @rpc_context.method.client_streaming?
92
+
93
+ block ? @original.each_message(&block) : @original.each_message
94
+ end
95
+ end
96
+
97
+ class Error
98
+ Field = Struct.new(:field_name, :error_code, :message)
99
+ DebugInfo = Struct.new(:detail, :stack_trace)
100
+ attr_accessor :code, :app_code, :message
101
+ attr_reader :field_errors, :debug_info, :metadata
102
+
103
+ def initialize
104
+ @field_errors = []
105
+ @metadata = {}
106
+ end
107
+
108
+ def add_field_error(field, code, message = "") = @field_errors << Field.new(field, code, message)
109
+ def has_field_errors? = !@field_errors.empty? # rubocop:disable Naming/PredicatePrefix -- Preserve the upstream Gruf method name.
110
+
111
+ def set_debug_info(detail, stack_trace = [])
112
+ @debug_info = DebugInfo.new(detail, stack_trace.is_a?(String) ? stack_trace.split("\n") : Array(stack_trace))
113
+ end
114
+
115
+ def metadata=(value)
116
+ @metadata = value.transform_values(&:to_s)
117
+ end
118
+
119
+ def to_h
120
+ { code:, app_code:, message:, field_errors: field_errors.map(&:to_h), debug_info: debug_info.to_h }
121
+ end
122
+
123
+ def fail!(_active_call = nil)
124
+ canonical = { bad_request: :invalid_argument, unauthorized: :permission_denied }.fetch(code, code)
125
+ trailers = metadata.merge("error-internal-bin" => JSON.generate(to_h))
126
+ raise Gritz::Errors.for_code(canonical).new(message, metadata: trailers)
127
+ end
128
+ end
129
+
130
+ module ErrorHelpers
131
+ def add_field_error(...) = error.add_field_error(...)
132
+ def has_field_errors? = error.has_field_errors? # rubocop:disable Naming/PredicatePrefix -- Preserve the upstream Gruf method name.
133
+ def set_debug_info(...) = error.set_debug_info(...)
134
+
135
+ def fail!(code, app_code = nil, message = "", metadata = {})
136
+ error.code = code.to_sym
137
+ error.app_code = app_code ? app_code.to_sym : error.code
138
+ error.message = message.to_s
139
+ error.metadata = metadata
140
+ error.fail!(request.active_call)
141
+ end
142
+ end
143
+
144
+ # Controller base retaining selected Gruf request, error and streaming APIs.
145
+ # @api public
146
+ class Controller < Gritz::Controller
147
+ include ErrorHelpers
148
+
149
+ def self.bound_service = service_class
150
+
151
+ def self.action_defined?(action)
152
+ super && ![Controller, ErrorHelpers].include?(instance_method(action).owner)
153
+ end
154
+
155
+ def initialize(context:)
156
+ super
157
+ @request = Gruf.request_for(context)
158
+ end
159
+
160
+ def error = request.error
161
+
162
+ def process(action)
163
+ result = super
164
+ return result unless context.method.server_streaming?
165
+
166
+ result.each { |message| stream.write(message) } if result.respond_to?(:each)
167
+ nil
168
+ end
169
+ end
170
+
171
+ Base = Controller
172
+
173
+ # Base for migrated interceptors whose #call yields to the next handler.
174
+ # @api public
175
+ class ServerInterceptor
176
+ include ErrorHelpers
177
+
178
+ attr_reader :request, :error, :options
179
+
180
+ def initialize(request, error, options = {})
181
+ @request = request
182
+ @error = error
183
+ @options = options
184
+ end
185
+ end
186
+
187
+ class InterceptorAdapter
188
+ def initialize(app, klass, options)
189
+ @app = app
190
+ @klass = klass
191
+ @options = options
192
+ end
193
+
194
+ def call(context)
195
+ request = Gruf.request_for(context)
196
+ @klass.new(request, request.error, @options.dup).call { @app.call(context) }
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -10,7 +10,7 @@ module Gritz
10
10
  workers: 0, threads: 16, max_waiting_requests: 64,
11
11
  transport: :native, listener_strategy: :reuseport,
12
12
  bind: "0.0.0.0:50051", admin_bind: "127.0.0.1:9090",
13
- fork_mode: :clean, fork_guard: :raise, strict_routes: false,
13
+ fork_mode: :clean, fork_guard: :raise, strict_routes: false, reflection: false,
14
14
  drain_delay: 5.0, shutdown_timeout: 25.0, worker_boot_timeout: 60.0,
15
15
  worker_timeout: 30.0, status_interval: 1.0, min_ready_workers: 1,
16
16
  phased_restart_surge: 1, max_connection_age: 300.0,
@@ -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?
@@ -148,6 +153,7 @@ module Gritz
148
153
 
149
154
  def preload!
150
155
  @preloaders.each(&:call)
156
+ Core::LOADER.eager_load
151
157
  end
152
158
 
153
159
  def add_hook(name, &block)
@@ -42,7 +42,7 @@ module Gritz
42
42
 
43
43
  def initialize(context:)
44
44
  @context = context
45
- @request = Request.new(context.call, context)
45
+ @request = Request.for(context)
46
46
  @stream = Stream.new(context.call, context)
47
47
  end
48
48
 
@@ -93,6 +93,13 @@ module Gritz
93
93
 
94
94
  # @api public
95
95
  class Request
96
+ STORE_KEY = Object.new.freeze
97
+
98
+ # @api private
99
+ def self.for(context)
100
+ context.store[STORE_KEY] ||= new(context.call, context)
101
+ end
102
+
96
103
  def initialize(call, context)
97
104
  @call = call
98
105
  @context = context
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gritz
4
4
  module Core
5
- VERSION = "0.4.0"
5
+ VERSION = "0.6.0"
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"
@@ -23,6 +23,7 @@ module Gritz
23
23
  @metrics = metrics
24
24
  @logger = logger
25
25
  @clients = {}
26
+ @read_buffer = +""
26
27
  end
27
28
 
28
29
  def ios = [@io, *@clients.keys]
@@ -68,7 +69,7 @@ module Gritz
68
69
  end
69
70
 
70
71
  def read_request(socket, client)
71
- chunk = socket.read_nonblock(4096, exception: false)
72
+ chunk = socket.read_nonblock(4096, @read_buffer, exception: false)
72
73
  return if chunk == :wait_readable
73
74
  return drop(socket) if chunk.nil?
74
75