gritz-core 0.3.0 → 0.5.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: 03bd5114b9267c0bc0b5daa80d14433d4941c753321338fc5b83b2281f4d6bf1
4
- data.tar.gz: 67fabc1d94c5cca35149679a2e582c983a88122a5299638d05c639387ea48fa4
3
+ metadata.gz: ed1b7320b17241d7bf44746aff7108965c5bd4ef5c5acfeb26bade59786c5775
4
+ data.tar.gz: 062fa21a3446621c20990013a0bb7a6dd23c38803d5e58597902f83079dba21c
5
5
  SHA512:
6
- metadata.gz: 22ba419be6cd7bf1754eaccc7af3bde09ac0ca9dcac494f5d1932306aa73d595e0861b1c9ff1af9e694195f86d6c19e3103161a721ccd8e3f4c88ae72c93c56d
7
- data.tar.gz: 1986b1cc2c4a32d0baf218b8268b1ced8399931c1a48379f46071f891c09d4c89cfe489fb707683dcf0d4ed239c708b3bfdd36a156efb0696f14d1764a10eeb6
6
+ metadata.gz: 0f4702d111e9e72546b8a0a219703e355fba71a7ecbcd064d7c2fca60461789ee8c5c03dc98d050d843302f71506e9a9169bc88c9c8d73671231ff8ca8ca6035
7
+ data.tar.gz: 3fc17735fed2dc9068e54aae5853d6595d344458cb535eaa3be2ec8a63d2cd1c5d1f546175183639b3fa6f3309506bfa8d3041b91211456982023b187b6cb1a7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - Migrate Gruf controllers and server interceptors through `Gritz::Compat::Gruf`, with a configuration conversion tool.
6
+ - Configure optional gRPC Reflection through the `reflection` setting.
7
+ - Avoid unnecessary supervisor snapshot allocation when status consumers are paused.
8
+ - Publish worker metrics at `status_interval` to reduce supervisor overhead; retain retries and final shutdown totals.
9
+ - Eagerly load the transport-independent framework before application warmup so forked workers can share its memory.
10
+ - Send supervisor snapshots at `status_interval`, with immediate lifecycle and health changes, to reduce parent-process allocation.
11
+ - Reuse signal, status and admin read buffers to avoid allocating memory on every idle poll.
12
+
13
+ ## 0.4.0
14
+
15
+ - Define lazy, fork-safe clients with process-local shared connections, per-call middleware, deadline propagation and selected request headers.
16
+ - Treat unhandled downstream errors as safe `INTERNAL` responses by default; allow explicit status passthrough.
17
+ - Support optional worker telemetry integrations with the remaining graceful shutdown budget.
18
+
3
19
  ## 0.3.0
4
20
 
5
21
  - Replace workers one at a time with `USR1` and recycle them by request count, memory or lifetime.
data/README.md CHANGED
@@ -12,6 +12,10 @@ Controller tests can load `gritz/testing/rspec` or `gritz/testing/minitest` and
12
12
 
13
13
  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
14
 
15
+ `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
+
17
+ 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.
18
+
15
19
  ## Development
16
20
 
17
21
  ```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
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ # Shares transport connections within a process, never across a fork.
5
+ # @api private
6
+ class ChannelRegistry
7
+ module ForkHook
8
+ def _fork(...)
9
+ result = super
10
+ Gritz::ChannelRegistry.reset! if result.zero?
11
+ result
12
+ end
13
+ end
14
+
15
+ class << self
16
+ def fetch(target:, credentials:, args: {}, pool: nil)
17
+ reset! unless @pid == Process.pid
18
+ key = [Process.pid, pool, snapshot(target), credentials, snapshot(args)].freeze
19
+ @lock.synchronize { @channels.fetch(key) { @channels[key] = yield } }
20
+ end
21
+
22
+ # In a child, discard references without calling native close methods.
23
+ def reset!
24
+ @lock = Mutex.new
25
+ @channels = {}
26
+ @pid = Process.pid
27
+ end
28
+
29
+ def snapshot(value)
30
+ case value
31
+ when String then value.dup.freeze
32
+ when Array then value.map { |entry| snapshot(entry) }.freeze
33
+ when Hash then value.to_h { |key, entry| [snapshot(key), snapshot(entry)] }.freeze
34
+ else value
35
+ end
36
+ end
37
+ end
38
+
39
+ reset!
40
+ Process.singleton_class.prepend(ForkHook) unless Process.singleton_class.ancestors.include?(ForkHook)
41
+ end
42
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Gritz
6
+ class Client
7
+ # Middleware state for an outgoing RPC, independent of its transport.
8
+ # @api public
9
+ class CallContext
10
+ attr_reader :method, :kind, :options, :parent, :store, :logger
11
+ attr_accessor :request, :operation, :trace_context, :response_block
12
+
13
+ def initialize(method:, kind:, request:, options:, parent:, terminal:)
14
+ service, name = method.delete_prefix("/").split("/", 2)
15
+ @method = MethodDescriptor.new(service:, name:, input_type: nil, output_type: nil,
16
+ client_streaming: %i[client_streaming bidi].include?(kind),
17
+ server_streaming: %i[server_streaming bidi].include?(kind))
18
+ @kind = kind
19
+ @request = request
20
+ @options = options
21
+ @parent = parent
22
+ @terminal = terminal
23
+ @store = {}
24
+ @logger = parent&.logger || Logger.new($stderr)
25
+ @trace_context = parent&.trace_context
26
+ end
27
+
28
+ def metadata = options[:metadata]
29
+ def deadline = options[:deadline]
30
+ def remaining = deadline && [deadline - Time.now, 0.0].max
31
+ def request_id = metadata["x-request-id"]
32
+ def cancelled? = !!(store[:gritz_cancelled] || parent&.cancelled? || operation&.cancelled?)
33
+
34
+ def check_deadline!
35
+ raise Errors::DeadlineExceeded, "deadline exceeded" if deadline && remaining.zero?
36
+ end
37
+
38
+ def check_cancelled!
39
+ raise Errors::Cancelled, "request cancelled" if cancelled?
40
+ end
41
+
42
+ # Input streams stay lazy and retain the parent even on native writer threads.
43
+ # @api private
44
+ def each_request
45
+ Enumerator.new do |output|
46
+ Context.with(parent) do
47
+ request.each do |message|
48
+ check_deadline!
49
+ check_cancelled!
50
+ output << message
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ # @api private
57
+ def execute = @terminal.call(self, &response_block)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Gritz
6
+ class Client
7
+ # Captures the deadline, parent context, and middleware for one outgoing RPC.
8
+ # @api private
9
+ class Invocation
10
+ attr_reader :options
11
+
12
+ def initialize(deadline: nil, safety_margin: 0.01, options: {}, parent: nil, middleware: nil)
13
+ validate_duration!(deadline, :deadline, positive: true) unless deadline.nil?
14
+ validate_duration!(safety_margin, :safety_margin, positive: false)
15
+ raise ArgumentError, "return_op is not supported by Gritz::Client" if options[:return_op]
16
+
17
+ @parent = parent
18
+ @options = options.dup
19
+ explicit = options[:deadline]
20
+ raise ArgumentError, "deadline must be an absolute Time" unless explicit.nil? || explicit.is_a?(Time)
21
+
22
+ now = Time.now
23
+ candidates = [explicit, deadline && (now + deadline), parent&.deadline && (parent.deadline - safety_margin)].compact
24
+ @options[:deadline] = candidates.min
25
+ @options[:metadata] = prepare_metadata(options.fetch(:metadata, {}))
26
+ check_parent!
27
+ raise Errors::DeadlineExceeded, "deadline exceeded" if @options[:deadline] && @options[:deadline] <= now
28
+
29
+ stack = Client.middleware.dup
30
+ stack.entries.concat(middleware.entries) if middleware
31
+ @app = stack.build(lambda(&:execute))
32
+ end
33
+
34
+ def call(terminal, method:, kind:, request:, options:, &block)
35
+ context = CallContext.new(method:, kind:, request:, options:, parent: @parent, terminal:)
36
+ if context.method.server_streaming? && !block
37
+ Enumerator.new { |output| run(context) { |reply| output << reply } }
38
+ else
39
+ run(context, &block)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def run(context, &block)
46
+ Context.with(@parent) do
47
+ context.check_deadline!
48
+ context.check_cancelled!
49
+ context.response_block = block
50
+ @app.call(context)
51
+ end
52
+ end
53
+
54
+ def check_parent!
55
+ raise Errors::Cancelled, "request cancelled" if @parent&.cancelled?
56
+ end
57
+
58
+ def prepare_metadata(metadata)
59
+ raise ArgumentError, "metadata must be a Hash" unless metadata.is_a?(Hash)
60
+
61
+ copied = metadata.to_h { |key, value| [key.to_s, copy_metadata_value(value)] }
62
+ copied["x-request-id"] ||= copy_metadata_value(@parent&.request_id) || SecureRandom.uuid
63
+ %w[traceparent tracestate].each do |key|
64
+ value = @parent&.metadata&.[](key)
65
+ copied[key] ||= copy_metadata_value(value) if value
66
+ end
67
+ copied
68
+ end
69
+
70
+ def copy_metadata_value(value)
71
+ case value
72
+ when Array then value.map { |item| item.is_a?(String) ? item.dup : item }
73
+ when String then value.dup
74
+ else value
75
+ end
76
+ end
77
+
78
+ def validate_duration!(value, name, positive:)
79
+ valid = value.is_a?(Numeric) && value.real? && value.finite? && (positive ? value.positive? : value >= 0)
80
+ raise ArgumentError, "#{name} must be a finite #{positive ? 'positive' : 'nonnegative'} number" unless valid
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Gritz
6
+ # Defines fork-safe singleton clients without constructing a native transport.
7
+ # @api public
8
+ class Client
9
+ class << self
10
+ attr_accessor :adapter
11
+
12
+ def middleware = @middleware ||= Middleware::Stack.new
13
+
14
+ def middleware=(stack)
15
+ raise ArgumentError, "middleware must be a Gritz::Middleware::Stack" unless stack.is_a?(Middleware::Stack)
16
+
17
+ @middleware = stack
18
+ end
19
+
20
+ def define(stub_class, target:, credentials: :insecure, channel_args: {}, deadline: nil,
21
+ safety_margin: 0.01, service_config: nil, middleware: nil, adapter: nil)
22
+ unless stub_class.is_a?(Class) && !stub_class.public_instance_methods(false).empty?
23
+ raise ArgumentError, "stub_class must define RPC methods"
24
+ end
25
+ raise ArgumentError, "target must be a nonempty String" unless target.is_a?(String) && !target.strip.empty? && !target.include?("\0")
26
+ raise ArgumentError, "channel_args must be a Hash" unless channel_args.is_a?(Hash)
27
+
28
+ validate_time!(:deadline, deadline) unless deadline.nil?
29
+ validate_time!(:safety_margin, safety_margin)
30
+ if middleware && !middleware.is_a?(Middleware::Stack)
31
+ raise ArgumentError, "middleware must be a Gritz::Middleware::Stack"
32
+ end
33
+
34
+ args = channel_args.dup
35
+ unless service_config.nil?
36
+ raise ArgumentError, "service_config must be a JSON object" unless service_config.is_a?(Hash)
37
+
38
+ args["grpc.service_config"] = JSON.generate(service_config)
39
+ validate_json!(service_config)
40
+ end
41
+ definition = Definition.new(stub_class:, target: target.dup.freeze, credentials:,
42
+ args: ChannelRegistry.snapshot(args), deadline:, safety_margin:, middleware:, adapter:)
43
+ Module.new do
44
+ stub_class.public_instance_methods(false).each do |method|
45
+ define_singleton_method(method) do |request, options = {}, **keywords, &block|
46
+ raise ArgumentError, "RPC options must be a Hash" unless options.is_a?(Hash)
47
+
48
+ definition.call(method, request, options.merge(keywords), &block)
49
+ end
50
+ end
51
+ end
52
+ rescue JSON::GeneratorError, JSON::NestingError => e
53
+ raise ArgumentError, "invalid service_config: #{e.message}"
54
+ end
55
+
56
+ private
57
+
58
+ def validate_time!(name, value)
59
+ unless value.is_a?(Numeric) && value.real? && value.finite? && (name == :deadline ? value.positive? : value >= 0)
60
+ raise ArgumentError, "#{name} must be a finite #{name == :deadline ? 'positive' : 'nonnegative'} number"
61
+ end
62
+ end
63
+
64
+ def validate_json!(value)
65
+ case value
66
+ when Hash
67
+ value.each do |key, entry|
68
+ raise ArgumentError, "service_config object keys must be Strings or Symbols" unless key.is_a?(String) || key.is_a?(Symbol)
69
+
70
+ validate_json!(entry)
71
+ end
72
+ when Array then value.each { |entry| validate_json!(entry) }
73
+ when Float
74
+ raise ArgumentError, "service_config numbers must be finite" unless value.finite?
75
+ when String, Integer, Symbol, TrueClass, FalseClass, NilClass then nil
76
+ else raise ArgumentError, "service_config must contain only JSON values"
77
+ end
78
+ end
79
+ end
80
+
81
+ # @api private
82
+ class Definition
83
+ def initialize(stub_class:, target:, credentials:, args:, deadline:, safety_margin:, middleware:, adapter:)
84
+ @stub_class = stub_class
85
+ @target = target
86
+ @credentials = credentials
87
+ @args = args
88
+ @deadline = deadline
89
+ @safety_margin = safety_margin
90
+ @middleware = middleware
91
+ @adapter = adapter
92
+ end
93
+
94
+ def call(method, request, options, &)
95
+ ForkGuard.check!(@stub_class)
96
+ invocation = Invocation.new(deadline: @deadline, safety_margin: @safety_margin, options:,
97
+ parent: Context.current, middleware: @middleware)
98
+ adapter = @adapter || Client.adapter
99
+ raise ArgumentError, "require gritz/native or provide a client adapter" unless adapter
100
+
101
+ connection = ChannelRegistry.fetch(target: @target, credentials: @credentials, args: @args, pool: adapter) do
102
+ adapter.connect(target: @target, credentials: @credentials, args: @args)
103
+ end
104
+ adapter.invoke(stub_class: @stub_class, connection:, method:, request:, options: invocation.options,
105
+ around: invocation.method(:call), &)
106
+ end
107
+ end
108
+ end
109
+ 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,
@@ -28,7 +28,7 @@ module Gritz
28
28
  }.freeze
29
29
  HOOKS = %i[before_fork on_worker_boot on_worker_shutdown].freeze
30
30
 
31
- attr_accessor(*DEFAULTS.keys, :controllers, :middleware, :preload_app)
31
+ attr_accessor(*DEFAULTS.keys, :controllers, :middleware, :preload_app, :metrics_recorder_factory)
32
32
  attr_reader :health_checks
33
33
 
34
34
  def initialize
@@ -98,6 +98,9 @@ module Gritz
98
98
  validate_tls!
99
99
  raise ConfigurationError, "controllers must be an Array of classes" unless controllers.is_a?(Array) && controllers.all?(Class)
100
100
  raise ConfigurationError, "middleware must be a Stack" unless middleware.is_a?(Middleware::Stack)
101
+ if metrics_recorder_factory && !metrics_recorder_factory.respond_to?(:call)
102
+ raise ConfigurationError, "metrics recorder factory must be callable"
103
+ end
101
104
  raise ConfigurationError, "preload_app must be boolean" unless [true, false].include?(preload_app)
102
105
  unless log_redact.all? { |name| name.is_a?(String) || name.is_a?(Symbol) }
103
106
  raise ConfigurationError, "log_redact must contain metadata names"
@@ -116,7 +119,9 @@ module Gritz
116
119
  validate!
117
120
  raise ConfigurationError, "This release supports transport :native" unless transport == :native
118
121
  raise ConfigurationError, "This release supports listener_strategy :reuseport" unless listener_strategy == :reuseport
119
- raise ConfigurationError, "This release supports metrics_backend :pipe" unless metrics_backend == :pipe
122
+ unless metrics_backend == :pipe || (metrics_backend == :otlp && metrics_recorder_factory)
123
+ raise ConfigurationError, "metrics_backend requires an installed recorder (:pipe is built in)"
124
+ end
120
125
  raise ConfigurationError, "This release supports phased_restart_surge 1" unless phased_restart_surge == 1
121
126
 
122
127
  unless worker_recycle.empty?
@@ -143,6 +148,7 @@ module Gritz
143
148
 
144
149
  def preload!
145
150
  @preloaders.each(&:call)
151
+ Core::LOADER.eager_load
146
152
  end
147
153
 
148
154
  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.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
data/lib/gritz/errors.rb CHANGED
@@ -6,14 +6,16 @@ module Gritz
6
6
  class Error < StandardError
7
7
  attr_reader :details, :metadata
8
8
 
9
- def initialize(message = nil, details: [], metadata: {})
9
+ def initialize(message = nil, details: [], metadata: {}, remote: false)
10
10
  super(message || code.to_s.tr("_", " "))
11
11
  @details = details
12
12
  @metadata = metadata
13
+ @remote = remote
13
14
  end
14
15
 
15
16
  def code = self.class::CODE
16
17
  def grpc_code = self.class::GRPC_CODE
18
+ def remote? = @remote
17
19
 
18
20
  CODE = :unknown
19
21
  GRPC_CODE = 2
@@ -56,6 +56,10 @@ module Gritz
56
56
  end
57
57
  end
58
58
 
59
+ # Optional process observations and shutdown for installed worker backends.
60
+ def observe_worker(_status); end
61
+ def close(timeout: nil); end
62
+
59
63
  # The caller retains this detached delta until its status channel accepts the row.
60
64
  def take_delta(max_bytes: Supervisor::StatusChannel::MAX_LINE_BYTES - 4096)
61
65
  raise ArgumentError, "Metric packet budget must be positive" unless max_bytes.is_a?(Integer) && max_bytes.positive?
@@ -7,16 +7,19 @@ module Gritz
7
7
  # Converts application exceptions to safe RPC errors.
8
8
  # @api public
9
9
  class ExceptionMapper
10
- def initialize(app, mappings: {}, expose_errors: false)
10
+ def initialize(app, mappings: {}, expose_errors: false, passthrough_remote_errors: false)
11
11
  @app = app
12
12
  @mappings = mappings
13
13
  @expose_errors = expose_errors
14
+ @passthrough_remote_errors = passthrough_remote_errors
14
15
  end
15
16
 
16
17
  def call(context)
17
18
  @app.call(context)
18
- rescue Gritz::Error
19
- raise
19
+ rescue Gritz::Error => e
20
+ raise unless e.remote? && !@passthrough_remote_errors
21
+
22
+ raise_internal(context, e)
20
23
  rescue StandardError => e
21
24
  mapping = @mappings.find { |klass, _| e.is_a?(klass) }&.last
22
25
  if mapping
@@ -30,9 +33,15 @@ module Gritz
30
33
  raise Errors::InvalidArgument, "invalid record"
31
34
  end
32
35
 
36
+ raise_internal(context, e)
37
+ end
38
+
39
+ private
40
+
41
+ def raise_internal(context, error)
33
42
  error_id = SecureRandom.uuid
34
- context.store[:gritz_error] = { error_id: error_id, error: e.class.name, message: e.message, backtrace: e.backtrace }
35
- raise Errors::Internal.new(@expose_errors ? e.message : "internal error (#{error_id})", metadata: { "error-id" => error_id })
43
+ context.store[:gritz_error] = { error_id: error_id, error: error.class.name, message: error.message, backtrace: error.backtrace }
44
+ raise Errors::Internal.new(@expose_errors ? error.message : "internal error (#{error_id})", metadata: { "error-id" => error_id })
36
45
  end
37
46
  end
38
47
  end
@@ -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
 
@@ -49,7 +49,7 @@ module Gritz
49
49
  end
50
50
  update_lifecycle
51
51
  @admin&.poll
52
- @observer&.write(status)
52
+ @observer&.write { status }
53
53
  @observer&.flush
54
54
  break if @stopping && @generations.empty?
55
55
 
@@ -50,7 +50,10 @@ module Gritz
50
50
  advance_replacement
51
51
  maintain_worker_count unless @shutdown_at
52
52
  flush_forwarded
53
- @reports&.write(@owner_channel ? status.merge(type: "status") : status) if @forwarded.empty?
53
+ if @reports && @forwarded.empty? && (!@next_report_at || now >= @next_report_at || @shutdown_at) &&
54
+ @reports.write { @owner_channel ? status.merge(type: "status") : status }
55
+ @next_report_at = now + @config.status_interval
56
+ end
54
57
  @admin&.poll
55
58
  if @owner_channel&.closed?
56
59
  @exit_status = 1
@@ -132,6 +135,7 @@ module Gritz
132
135
  handle = WorkerHandle.new(pid: pid, index: index, status_io: reader)
133
136
  handle.recycle_factor = 1.0 + (rand * @config.worker_recycle.fetch(:jitter, 0.0))
134
137
  @workers[pid] = handle
138
+ @next_report_at = nil
135
139
  @logger.info("Worker #{index} spawned pid=#{pid}")
136
140
  handle
137
141
  rescue StandardError
@@ -159,7 +163,9 @@ module Gritz
159
163
  end
160
164
  next
161
165
  end
166
+ changed = handle.state != message[:state] || (message.key?(:healthy) && handle.stats[:healthy] != message[:healthy])
162
167
  handle.update(message, now: now)
168
+ @next_report_at = nil if changed
163
169
  if message[:state] == "failed" && !@ever_ready
164
170
  @exit_status = 1
165
171
  begin_shutdown
@@ -207,6 +213,7 @@ module Gritz
207
213
  break if handle.channel.closed?
208
214
  end
209
215
  @workers.delete(pid)
216
+ @next_report_at = nil
210
217
  @metrics.forget(handle)
211
218
  handle.close
212
219
  child_status = result.last
@@ -223,10 +230,12 @@ module Gritz
223
230
  break unless @forwarded.empty?
224
231
  rescue Errno::ECHILD
225
232
  @workers.delete(pid)&.close
233
+ @next_report_at = nil
226
234
  end
227
235
  end
228
236
 
229
237
  def handle_signal(signal)
238
+ @next_report_at = nil
230
239
  case signal
231
240
  when "TERM", "INT" then begin_shutdown
232
241
  when "QUIT" then begin_shutdown(immediate: true)
@@ -301,6 +310,7 @@ module Gritz
301
310
  kill(fresh) if fresh
302
311
  @logger.warn("Replacement failed; keeping previous workers")
303
312
  @replacement = nil
313
+ @next_report_at = nil
304
314
  @replacement_queue.clear
305
315
  @recycle_retry_at = now + @config.worker_boot_timeout
306
316
  return true
@@ -314,6 +324,7 @@ module Gritz
314
324
  return if old || !@replacement[:retired]
315
325
 
316
326
  @replacement = nil
327
+ @next_report_at = nil
317
328
  end
318
329
  while (entry = @replacement_queue.shift)
319
330
  old_pid, reason = entry
@@ -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?
@@ -28,6 +28,7 @@ module Gritz
28
28
  @boot_started = true
29
29
  @config.preload! unless @config.preload_app?
30
30
  @config.run_hooks(:on_worker_boot, @index)
31
+ @recorder = @config.metrics_recorder_factory.call(worker: @index) if @config.metrics_recorder_factory
31
32
  router = Router.new(controllers: @config.controllers, strict: @config.strict_routes, logger: @logger)
32
33
  dispatcher = Dispatcher.new(router:, middleware: @config.middleware, logger: @logger, metrics: @recorder,
33
34
  worker: @index, log_format: @config.log_format, log_redact: @config.log_redact)
@@ -94,7 +95,7 @@ module Gritz
94
95
  if @pending_reexec && @owner_channel.write(type: "reexec", pid: Process.pid)
95
96
  @pending_reexec = false
96
97
  end
97
- publish_metrics
98
+ publish_metrics if @pending_delta || now >= next_status
98
99
  @admin&.poll
99
100
  if now >= next_status
100
101
  report(@draining ? "draining" : "ready")
@@ -114,6 +115,7 @@ module Gritz
114
115
  busy_threads: stats.fetch(:busy_threads, stats.fetch(:busy, 0)), healthy: state == "ready" && @healthy,
115
116
  checks: @checks || {}, worker_started_at: @born_at)
116
117
  @last_status = row.merge(extra)
118
+ @recorder.observe_worker(@last_status)
117
119
  @status&.write(@owner_channel ? snapshot : @last_status)
118
120
  end
119
121
 
@@ -184,6 +186,7 @@ module Gritz
184
186
  @status.io.wait_writable((deadline - monotonic).clamp(0, 0.01)) until !@status || @status.flush || @status.closed? || monotonic >= deadline
185
187
  ensure
186
188
  begin
189
+ cleanup { @recorder.close(timeout: [(deadline || monotonic) - monotonic, 0].max) }
187
190
  @signals&.close
188
191
  @admin&.close
189
192
  ensure
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gritz-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -113,15 +113,23 @@ description: Transport-independent controllers, routing, middleware, configurati
113
113
  and testing for Gritz.
114
114
  email:
115
115
  - t.yudai92@gmail.com
116
- executables: []
116
+ executables:
117
+ - gritz-migrate-gruf
117
118
  extensions: []
118
119
  extra_rdoc_files: []
119
120
  files:
120
121
  - CHANGELOG.md
121
122
  - LICENSE.txt
122
123
  - README.md
124
+ - exe/gritz-migrate-gruf
123
125
  - lib/gritz/call.rb
126
+ - lib/gritz/channel_registry.rb
124
127
  - lib/gritz/cli.rb
128
+ - lib/gritz/client.rb
129
+ - lib/gritz/client/call_context.rb
130
+ - lib/gritz/client/invocation.rb
131
+ - lib/gritz/compat/gruf.rb
132
+ - lib/gritz/compat/gruf/config_converter.rb
125
133
  - lib/gritz/configuration.rb
126
134
  - lib/gritz/configuration_error.rb
127
135
  - lib/gritz/context.rb