gritz-core 0.2.0 → 0.4.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 +13 -0
- data/README.md +4 -0
- data/lib/gritz/channel_registry.rb +42 -0
- data/lib/gritz/cli.rb +31 -5
- 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/configuration.rb +23 -9
- data/lib/gritz/context.rb +31 -2
- data/lib/gritz/controller.rb +14 -0
- data/lib/gritz/core/version.rb +1 -1
- data/lib/gritz/dispatcher.rb +9 -3
- data/lib/gritz/errors.rb +3 -1
- data/lib/gritz/metrics/aggregator.rb +119 -0
- data/lib/gritz/metrics/recorder.rb +93 -0
- data/lib/gritz/middleware/exception_mapper.rb +14 -7
- data/lib/gritz/middleware/logging.rb +27 -3
- data/lib/gritz/middleware/metrics.rb +29 -0
- data/lib/gritz/middleware/stack.rb +1 -1
- data/lib/gritz/supervisor/admin_server.rb +121 -0
- data/lib/gritz/supervisor/launcher.rb +381 -0
- data/lib/gritz/supervisor/master.rb +220 -29
- data/lib/gritz/supervisor/status_channel.rb +15 -3
- data/lib/gritz/supervisor/worker_handle.rb +4 -3
- data/lib/gritz/testing/cluster.rb +43 -7
- data/lib/gritz/worker/runner.rb +111 -16
- metadata +30 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 600c0b33c1d7f7008866ee20d789504559715612886cd3f8dc33413e5192cb36
|
|
4
|
+
data.tar.gz: c3797690d572a4d29f5275946b16f84b5717fb19e4f31fd6a20217ece840eb92
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f2858b3d8e39c28d309a3acce2255fcc42ec4aba071b2b79735025a840973ffe0016183204eb7e87c67b1a14f11fef48ffa9e6560234cfd5da0866ccd195f68f
|
|
7
|
+
data.tar.gz: 149a97e113197dc7691152d5aa0f7e246adb55cc0bb90a699b122078ea3272cd32787461808b78d7379804c83a487866f503d8eea7f135a5c356c011e40a2209
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- Define lazy, fork-safe clients with process-local shared connections, per-call middleware, deadline propagation and selected request headers.
|
|
6
|
+
- Treat unhandled downstream errors as safe `INTERNAL` responses by default; allow explicit status passthrough.
|
|
7
|
+
- Support optional worker telemetry integrations with the remaining graceful shutdown budget.
|
|
8
|
+
|
|
9
|
+
## 0.3.0
|
|
10
|
+
|
|
11
|
+
- Replace workers one at a time with `USR1` and recycle them by request count, memory or lifetime.
|
|
12
|
+
- Reload application code with `USR2`; retain the serving master if replacement startup fails, and update the PID file after readiness.
|
|
13
|
+
- Keep admin probes and Prometheus totals available across worker and master replacement. Add application health checks and per-worker status.
|
|
14
|
+
- Emit one structured completion log per RPC with JSON or logfmt output and credential redaction.
|
|
15
|
+
|
|
3
16
|
## 0.2.0
|
|
4
17
|
|
|
5
18
|
- Supervise forked workers with boot and heartbeat timeouts, automatic replacement, graceful shutdown, lifecycle hooks, dynamic worker counts and log reopening.
|
data/README.md
CHANGED
|
@@ -10,6 +10,10 @@ Use [gritz](https://github.com/gritzrpc/gritz) for the default framework and exe
|
|
|
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
|
+
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
|
+
|
|
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
|
+
|
|
13
17
|
## Development
|
|
14
18
|
|
|
15
19
|
```sh
|
|
@@ -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
|
data/lib/gritz/cli.rb
CHANGED
|
@@ -2,16 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
4
|
require "logger"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "socket"
|
|
5
7
|
|
|
6
8
|
module Gritz
|
|
7
9
|
# Starts a server, lists routes, or checks configuration and fork safety.
|
|
8
10
|
# @api public
|
|
9
11
|
class CLI
|
|
10
|
-
|
|
12
|
+
CHILD_BOOTSTRAP = <<~RUBY
|
|
13
|
+
require "socket"
|
|
14
|
+
fd = ENV.delete("GRITZ_INTERNAL_OWNER_FD")
|
|
15
|
+
exit Gritz::CLI.new(owner_io: UNIXSocket.for_fd(Integer(fd))).run(ARGV)
|
|
16
|
+
RUBY
|
|
17
|
+
|
|
18
|
+
def self.child_command(argv)
|
|
19
|
+
[RbConfig.ruby, "-I", $LOAD_PATH.join(File::PATH_SEPARATOR), "-rgritz/core", "-e", CHILD_BOOTSTRAP, "--", *argv]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(stdout: $stdout, stderr: $stderr, env: ENV, status_io: nil, owner_io: nil, launch: false, launch_command: nil)
|
|
11
23
|
@stdout = stdout
|
|
12
24
|
@stderr = stderr
|
|
13
25
|
@env = env
|
|
14
26
|
@status_io = status_io
|
|
27
|
+
@owner_channel = Supervisor::StatusChannel.new(owner_io) if owner_io
|
|
28
|
+
@launch = launch
|
|
29
|
+
@launch_command = launch_command
|
|
15
30
|
end
|
|
16
31
|
|
|
17
32
|
def run(argv)
|
|
@@ -39,12 +54,17 @@ module Gritz
|
|
|
39
54
|
raise ConfigurationError, "Unknown command #{command}" unless %w[start routes check].include?(command)
|
|
40
55
|
raise ConfigurationError, "Unexpected argument #{args.first}" unless args.empty?
|
|
41
56
|
|
|
57
|
+
logger = Logger.new(@stdout)
|
|
58
|
+
logger.formatter = ->(_severity, _time, _progname, message) { "#{message}\n" }
|
|
59
|
+
if command == "start" && @launch && !@owner_channel
|
|
60
|
+
return Supervisor::Launcher.new(command: @launch_command || self.class.child_command(argv), env: @env,
|
|
61
|
+
stdout: @stdout, stderr: @stderr, logger: logger, status_io: @status_io).run
|
|
62
|
+
end
|
|
63
|
+
|
|
42
64
|
path ||= "config/gritz.rb" if File.file?("config/gritz.rb")
|
|
43
65
|
guard = ForkGuard.activate(mode: :record) if command != "routes"
|
|
44
66
|
require "gritz/native" if command != "routes"
|
|
45
67
|
config = Configuration.load(path: path, env: @env, overrides: overrides)
|
|
46
|
-
logger = Logger.new(@stdout)
|
|
47
|
-
logger.formatter = ->(_severity, _time, _progname, message) { "#{message}\n" }
|
|
48
68
|
if command == "check"
|
|
49
69
|
config.preload! if config.preload_app?
|
|
50
70
|
guard.violations.each { |violation| @stderr.puts(violation.message) }
|
|
@@ -61,11 +81,16 @@ module Gritz
|
|
|
61
81
|
guard.violations.each { |violation| logger.warn(violation.message) } if config.fork_guard == :warn
|
|
62
82
|
end
|
|
63
83
|
@stdout.sync = true if @stdout.respond_to?(:sync=)
|
|
64
|
-
|
|
84
|
+
@owner_channel&.write(type: "configured", pid: Process.pid, workers: config.workers, bind: config.bind,
|
|
85
|
+
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)
|
|
87
|
+
if config.workers.positive?
|
|
88
|
+
return Supervisor::Master.new(config, logger: logger, status_io: @status_io, owner_channel: @owner_channel).run
|
|
89
|
+
end
|
|
65
90
|
|
|
66
91
|
ForkGuard.deactivate
|
|
67
92
|
config.preload! if config.preload_app?
|
|
68
|
-
return Worker::Runner.new(index: 0, config: config, logger: logger).run
|
|
93
|
+
return Worker::Runner.new(index: 0, config: config, logger: logger, owner_channel: @owner_channel).run
|
|
69
94
|
else
|
|
70
95
|
config.preload! if config.preload_app?
|
|
71
96
|
router = Router.new(controllers: config.controllers, strict: config.strict_routes, logger: logger)
|
|
@@ -81,6 +106,7 @@ module Gritz
|
|
|
81
106
|
1
|
|
82
107
|
ensure
|
|
83
108
|
ForkGuard.deactivate if guard
|
|
109
|
+
@owner_channel&.close
|
|
84
110
|
end
|
|
85
111
|
end
|
|
86
112
|
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
|
data/lib/gritz/configuration.rb
CHANGED
|
@@ -18,7 +18,8 @@ module Gritz
|
|
|
18
18
|
keepalive_permit_without_calls: true,
|
|
19
19
|
max_receive_message_size: 4 * 1024 * 1024,
|
|
20
20
|
max_send_message_size: 4 * 1024 * 1024, max_metadata_size: 8192,
|
|
21
|
-
metrics_backend: :pipe, log_format: :json, worker_recycle: {}, tls: {}
|
|
21
|
+
metrics_backend: :pipe, log_format: :json, log_redact: [], worker_recycle: {}, tls: {},
|
|
22
|
+
pid_file: "", reexec_timeout: 60.0
|
|
22
23
|
}.freeze
|
|
23
24
|
ENUMS = {
|
|
24
25
|
transport: %i[native async], listener_strategy: %i[reuseport inherited_fd port_per_worker],
|
|
@@ -27,7 +28,7 @@ module Gritz
|
|
|
27
28
|
}.freeze
|
|
28
29
|
HOOKS = %i[before_fork on_worker_boot on_worker_shutdown].freeze
|
|
29
30
|
|
|
30
|
-
attr_accessor(*DEFAULTS.keys, :controllers, :middleware, :preload_app)
|
|
31
|
+
attr_accessor(*DEFAULTS.keys, :controllers, :middleware, :preload_app, :metrics_recorder_factory)
|
|
31
32
|
attr_reader :health_checks
|
|
32
33
|
|
|
33
34
|
def initialize
|
|
@@ -63,7 +64,7 @@ module Gritz
|
|
|
63
64
|
when Integer then Integer(value, 10)
|
|
64
65
|
when Float then Float(value)
|
|
65
66
|
when Symbol then value.to_sym
|
|
66
|
-
when Hash then JSON.parse(value, symbolize_names: true)
|
|
67
|
+
when Hash, Array then JSON.parse(value, symbolize_names: true)
|
|
67
68
|
when true, false
|
|
68
69
|
return true if %w[true 1].include?(value)
|
|
69
70
|
return false if %w[false 0].include?(value)
|
|
@@ -86,7 +87,7 @@ module Gritz
|
|
|
86
87
|
end
|
|
87
88
|
raise ConfigurationError, "Invalid #{name}: #{value.inspect}" unless valid
|
|
88
89
|
end
|
|
89
|
-
%i[shutdown_timeout worker_boot_timeout worker_timeout status_interval].each do |name|
|
|
90
|
+
%i[shutdown_timeout worker_boot_timeout worker_timeout status_interval reexec_timeout].each do |name|
|
|
90
91
|
raise ConfigurationError, "#{name} must be positive" unless public_send(name).positive?
|
|
91
92
|
end
|
|
92
93
|
ENUMS.each do |name, allowed|
|
|
@@ -97,7 +98,16 @@ module Gritz
|
|
|
97
98
|
validate_tls!
|
|
98
99
|
raise ConfigurationError, "controllers must be an Array of classes" unless controllers.is_a?(Array) && controllers.all?(Class)
|
|
99
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
|
|
100
104
|
raise ConfigurationError, "preload_app must be boolean" unless [true, false].include?(preload_app)
|
|
105
|
+
unless log_redact.all? { |name| name.is_a?(String) || name.is_a?(Symbol) }
|
|
106
|
+
raise ConfigurationError, "log_redact must contain metadata names"
|
|
107
|
+
end
|
|
108
|
+
unless health_checks.all? { |name, check| (name.is_a?(String) || name.is_a?(Symbol)) && check.respond_to?(:call) }
|
|
109
|
+
raise ConfigurationError, "health_checks must contain named callbacks"
|
|
110
|
+
end
|
|
101
111
|
|
|
102
112
|
self
|
|
103
113
|
end
|
|
@@ -109,11 +119,15 @@ module Gritz
|
|
|
109
119
|
validate!
|
|
110
120
|
raise ConfigurationError, "This release supports transport :native" unless transport == :native
|
|
111
121
|
raise ConfigurationError, "This release supports listener_strategy :reuseport" unless listener_strategy == :reuseport
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
raise ConfigurationError, "This release supports
|
|
116
|
-
|
|
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
|
|
125
|
+
raise ConfigurationError, "This release supports phased_restart_surge 1" unless phased_restart_surge == 1
|
|
126
|
+
|
|
127
|
+
unless worker_recycle.empty?
|
|
128
|
+
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")
|
|
130
|
+
end
|
|
117
131
|
raise ConfigurationError, "grpc_fork_support requires workers > 0" if workers.zero? && fork_mode != :clean
|
|
118
132
|
raise ConfigurationError, "at least one controller must be registered" if controllers.empty?
|
|
119
133
|
|
data/lib/gritz/context.rb
CHANGED
|
@@ -4,13 +4,19 @@ module Gritz
|
|
|
4
4
|
# Request state inherited by child fibers and threads using Ruby Fiber storage.
|
|
5
5
|
# @api public
|
|
6
6
|
class Context
|
|
7
|
-
attr_reader :call, :logger, :store
|
|
7
|
+
attr_reader :call, :logger, :store, :metrics, :worker, :log_format, :log_redact,
|
|
8
|
+
:requests_count, :responses_count, :bytes_in, :bytes_out
|
|
8
9
|
attr_accessor :request_id, :trace_context
|
|
9
10
|
|
|
10
|
-
def initialize(call:, logger:)
|
|
11
|
+
def initialize(call:, logger:, metrics: nil, worker: nil, log_format: :json, log_redact: [])
|
|
11
12
|
@call = call
|
|
12
13
|
@logger = logger
|
|
13
14
|
@store = {}
|
|
15
|
+
@metrics = metrics
|
|
16
|
+
@worker = worker
|
|
17
|
+
@log_format = log_format
|
|
18
|
+
@log_redact = log_redact.map(&:to_s)
|
|
19
|
+
@requests_count = @responses_count = @bytes_in = @bytes_out = 0
|
|
14
20
|
end
|
|
15
21
|
|
|
16
22
|
def self.current = Fiber[:gritz_context]
|
|
@@ -46,5 +52,28 @@ module Gritz
|
|
|
46
52
|
|
|
47
53
|
message
|
|
48
54
|
end
|
|
55
|
+
|
|
56
|
+
# @api private
|
|
57
|
+
def record_received(message)
|
|
58
|
+
return unless message
|
|
59
|
+
|
|
60
|
+
size = encoded_size(message)
|
|
61
|
+
@requests_count += 1
|
|
62
|
+
@bytes_in += size
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @api private
|
|
66
|
+
def record_sent(message)
|
|
67
|
+
size = encoded_size(message)
|
|
68
|
+
@responses_count += 1
|
|
69
|
+
@bytes_out += size
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def encoded_size(message)
|
|
75
|
+
# ponytail: byte accounting encodes again; instrument native serialization if the Phase 6 overhead budget is exceeded.
|
|
76
|
+
message.class.respond_to?(:encode) ? message.class.encode(message).bytesize : message.to_s.bytesize
|
|
77
|
+
end
|
|
49
78
|
end
|
|
50
79
|
end
|
data/lib/gritz/controller.rb
CHANGED
|
@@ -96,6 +96,10 @@ module Gritz
|
|
|
96
96
|
def initialize(call, context)
|
|
97
97
|
@call = call
|
|
98
98
|
@context = context
|
|
99
|
+
unless context.method.client_streaming?
|
|
100
|
+
@message = @call.read
|
|
101
|
+
@context.record_received(@message)
|
|
102
|
+
end
|
|
99
103
|
end
|
|
100
104
|
|
|
101
105
|
def message
|
|
@@ -104,14 +108,23 @@ module Gritz
|
|
|
104
108
|
return @message if defined?(@message)
|
|
105
109
|
|
|
106
110
|
@message = @call.read
|
|
111
|
+
@context.record_received(@message)
|
|
112
|
+
@message
|
|
107
113
|
end
|
|
108
114
|
|
|
109
115
|
def each_message
|
|
110
116
|
return enum_for(__method__) unless block_given?
|
|
111
117
|
|
|
118
|
+
unless @context.method.client_streaming?
|
|
119
|
+
value = message
|
|
120
|
+
yield value if value
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
|
|
112
124
|
@call.each_message do |message|
|
|
113
125
|
@context.check_deadline!
|
|
114
126
|
@context.check_cancelled!
|
|
127
|
+
@context.record_received(message)
|
|
115
128
|
yield message
|
|
116
129
|
end
|
|
117
130
|
end
|
|
@@ -133,6 +146,7 @@ module Gritz
|
|
|
133
146
|
@context.check_cancelled!
|
|
134
147
|
@context.validate_response!(message)
|
|
135
148
|
@call.write(message)
|
|
149
|
+
@context.record_sent(message)
|
|
136
150
|
end
|
|
137
151
|
end
|
|
138
152
|
end
|
data/lib/gritz/core/version.rb
CHANGED
data/lib/gritz/dispatcher.rb
CHANGED
|
@@ -8,9 +8,11 @@ module Gritz
|
|
|
8
8
|
class Dispatcher
|
|
9
9
|
attr_reader :router
|
|
10
10
|
|
|
11
|
-
def initialize(router:, middleware: Middleware::Stack.default, logger: Logger.new($stdout)
|
|
11
|
+
def initialize(router:, middleware: Middleware::Stack.default, logger: Logger.new($stdout),
|
|
12
|
+
metrics: nil, worker: nil, log_format: :json, log_redact: [])
|
|
12
13
|
@router = router
|
|
13
14
|
@logger = logger
|
|
15
|
+
@context_options = { metrics: metrics, worker: worker, log_format: log_format, log_redact: log_redact }
|
|
14
16
|
endpoint = lambda do |context|
|
|
15
17
|
context.check_deadline!
|
|
16
18
|
context.check_cancelled!
|
|
@@ -18,13 +20,17 @@ module Gritz
|
|
|
18
20
|
result = descriptor.controller.new(context:).process(descriptor.action)
|
|
19
21
|
context.check_deadline!
|
|
20
22
|
context.check_cancelled!
|
|
21
|
-
descriptor.server_streaming?
|
|
23
|
+
unless descriptor.server_streaming?
|
|
24
|
+
context.validate_response!(result)
|
|
25
|
+
context.record_sent(result)
|
|
26
|
+
end
|
|
27
|
+
result
|
|
22
28
|
end
|
|
23
29
|
@app = middleware ? middleware.build(endpoint) : endpoint
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
def call(call)
|
|
27
|
-
@app.call(Context.new(call:, logger: @logger))
|
|
33
|
+
@app.call(Context.new(call:, logger: @logger, **@context_options))
|
|
28
34
|
end
|
|
29
35
|
end
|
|
30
36
|
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
|