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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +4 -0
- data/exe/gritz-migrate-gruf +25 -0
- data/lib/gritz/channel_registry.rb +42 -0
- data/lib/gritz/client/call_context.rb +60 -0
- data/lib/gritz/client/invocation.rb +84 -0
- data/lib/gritz/client.rb +109 -0
- data/lib/gritz/compat/gruf/config_converter.rb +159 -0
- data/lib/gritz/compat/gruf.rb +201 -0
- data/lib/gritz/configuration.rb +9 -3
- data/lib/gritz/controller.rb +8 -1
- data/lib/gritz/core/version.rb +1 -1
- data/lib/gritz/errors.rb +3 -1
- data/lib/gritz/metrics/recorder.rb +4 -0
- data/lib/gritz/middleware/exception_mapper.rb +14 -5
- data/lib/gritz/supervisor/admin_server.rb +2 -1
- data/lib/gritz/supervisor/launcher.rb +1 -1
- data/lib/gritz/supervisor/master.rb +12 -1
- data/lib/gritz/supervisor/signal_queue.rb +2 -1
- data/lib/gritz/supervisor/status_channel.rb +4 -2
- data/lib/gritz/worker/runner.rb +4 -1
- metadata +11 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ed1b7320b17241d7bf44746aff7108965c5bd4ef5c5acfeb26bade59786c5775
|
|
4
|
+
data.tar.gz: 062fa21a3446621c20990013a0bb7a6dd23c38803d5e58597902f83079dba21c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/gritz/client.rb
ADDED
|
@@ -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
|
data/lib/gritz/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
data/lib/gritz/controller.rb
CHANGED
|
@@ -42,7 +42,7 @@ module Gritz
|
|
|
42
42
|
|
|
43
43
|
def initialize(context:)
|
|
44
44
|
@context = context
|
|
45
|
-
@request = Request.
|
|
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
|
data/lib/gritz/core/version.rb
CHANGED
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:
|
|
35
|
-
raise Errors::Internal.new(@expose_errors ?
|
|
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
|
|
|
@@ -50,7 +50,10 @@ module Gritz
|
|
|
50
50
|
advance_replacement
|
|
51
51
|
maintain_worker_count unless @shutdown_at
|
|
52
52
|
flush_forwarded
|
|
53
|
-
@reports
|
|
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?
|
data/lib/gritz/worker/runner.rb
CHANGED
|
@@ -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.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
8
|
-
bindir:
|
|
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
|