gritz-core 0.1.0 → 0.3.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 +3 -1
- data/lib/gritz/cli.rb +62 -36
- data/lib/gritz/configuration.rb +30 -14
- 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/fork_guard.rb +75 -0
- data/lib/gritz/metrics/aggregator.rb +119 -0
- data/lib/gritz/metrics/recorder.rb +89 -0
- data/lib/gritz/middleware/exception_mapper.rb +1 -3
- 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 +426 -0
- data/lib/gritz/supervisor/signal_queue.rb +49 -0
- data/lib/gritz/supervisor/status_channel.rb +124 -0
- data/lib/gritz/supervisor/worker_handle.rb +35 -0
- data/lib/gritz/testing/cluster.rb +186 -0
- data/lib/gritz/worker/runner.rb +203 -0
- metadata +34 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 03bd5114b9267c0bc0b5daa80d14433d4941c753321338fc5b83b2281f4d6bf1
|
|
4
|
+
data.tar.gz: 67fabc1d94c5cca35149679a2e582c983a88122a5299638d05c639387ea48fa4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 22ba419be6cd7bf1754eaccc7af3bde09ac0ca9dcac494f5d1932306aa73d595e0861b1c9ff1af9e694195f86d6c19e3103161a721ccd8e3f4c88ae72c93c56d
|
|
7
|
+
data.tar.gz: 1986b1cc2c4a32d0baf218b8268b1ced8399931c1a48379f46071f891c09d4c89cfe489fb707683dcf0d4ed239c708b3bfdd36a156efb0696f14d1764a10eeb6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- Replace workers one at a time with `USR1` and recycle them by request count, memory or lifetime.
|
|
6
|
+
- Reload application code with `USR2`; retain the serving master if replacement startup fails, and update the PID file after readiness.
|
|
7
|
+
- Keep admin probes and Prometheus totals available across worker and master replacement. Add application health checks and per-worker status.
|
|
8
|
+
- Emit one structured completion log per RPC with JSON or logfmt output and credential redaction.
|
|
9
|
+
|
|
10
|
+
## 0.2.0
|
|
11
|
+
|
|
12
|
+
- Supervise forked workers with boot and heartbeat timeouts, automatic replacement, graceful shutdown, lifecycle hooks, dynamic worker counts and log reopening.
|
|
13
|
+
- Detect unsafe master-side gRPC initialization with ForkGuard and `gritz check`.
|
|
14
|
+
- Add `Gritz::Testing::Cluster` for process lifecycle integration tests.
|
|
15
|
+
|
|
3
16
|
## 0.1.0
|
|
4
17
|
|
|
5
18
|
Initial release.
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Gritz Core
|
|
2
2
|
|
|
3
|
-
Transport-independent controllers, routing, middleware, configuration and network-free testing for Ruby gRPC applications. Requires CRuby 3.3 or later. This gem has no grpc dependency
|
|
3
|
+
Transport-independent controllers, routing, middleware, configuration, forked-worker supervision and network-free testing for Ruby gRPC applications. Requires CRuby 3.3 or later. This gem has no grpc dependency. Requiring the core does not load a transport; starting a server loads the selected adapter.
|
|
4
4
|
|
|
5
5
|
```ruby
|
|
6
6
|
require "gritz/core"
|
|
@@ -10,6 +10,8 @@ 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
|
+
|
|
13
15
|
## Development
|
|
14
16
|
|
|
15
17
|
```sh
|
data/lib/gritz/cli.rb
CHANGED
|
@@ -2,15 +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
|
|
26
|
+
@status_io = status_io
|
|
27
|
+
@owner_channel = Supervisor::StatusChannel.new(owner_io) if owner_io
|
|
28
|
+
@launch = launch
|
|
29
|
+
@launch_command = launch_command
|
|
14
30
|
end
|
|
15
31
|
|
|
16
32
|
def run(argv)
|
|
@@ -18,7 +34,7 @@ module Gritz
|
|
|
18
34
|
path = nil
|
|
19
35
|
overrides = {}
|
|
20
36
|
parser = OptionParser.new do |options|
|
|
21
|
-
options.banner = "Usage: gritz [start|routes] [-C config/gritz.rb] [options]"
|
|
37
|
+
options.banner = "Usage: gritz [start|routes|check] [-C config/gritz.rb] [options]"
|
|
22
38
|
options.on("-C", "--config PATH", "Configuration file") { |value| path = value }
|
|
23
39
|
options.on("--workers N", Integer) { |value| overrides[:workers] = value }
|
|
24
40
|
options.on("--threads N", Integer) { |value| overrides[:threads] = value }
|
|
@@ -35,52 +51,62 @@ module Gritz
|
|
|
35
51
|
end
|
|
36
52
|
parser.parse!(args)
|
|
37
53
|
command = args.shift || "start"
|
|
38
|
-
raise ConfigurationError, "Unknown command #{command}" unless %w[start routes].include?(command)
|
|
54
|
+
raise ConfigurationError, "Unknown command #{command}" unless %w[start routes check].include?(command)
|
|
39
55
|
raise ConfigurationError, "Unexpected argument #{args.first}" unless args.empty?
|
|
40
56
|
|
|
41
|
-
path ||= "config/gritz.rb" if File.file?("config/gritz.rb")
|
|
42
|
-
config = Configuration.load(path: path, env: @env, overrides: overrides)
|
|
43
|
-
config.validate_single_process! if command == "start"
|
|
44
|
-
config.preload! if config.preload_app?
|
|
45
57
|
logger = Logger.new(@stdout)
|
|
46
58
|
logger.formatter = ->(_severity, _time, _progname, message) { "#{message}\n" }
|
|
47
|
-
|
|
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
|
+
|
|
64
|
+
path ||= "config/gritz.rb" if File.file?("config/gritz.rb")
|
|
65
|
+
guard = ForkGuard.activate(mode: :record) if command != "routes"
|
|
66
|
+
require "gritz/native" if command != "routes"
|
|
67
|
+
config = Configuration.load(path: path, env: @env, overrides: overrides)
|
|
68
|
+
if command == "check"
|
|
69
|
+
config.preload! if config.preload_app?
|
|
70
|
+
guard.violations.each { |violation| @stderr.puts(violation.message) }
|
|
71
|
+
return 1 unless guard.violations.empty?
|
|
72
|
+
|
|
73
|
+
config.validate_runtime!
|
|
74
|
+
Router.new(controllers: config.controllers, strict: config.strict_routes, logger: logger)
|
|
75
|
+
@stdout.puts "Configuration and fork safety checks passed"
|
|
76
|
+
elsif command == "start"
|
|
77
|
+
config.validate_runtime!
|
|
78
|
+
if config.workers.positive? && config.fork_mode == :clean
|
|
79
|
+
raise guard.violations.first if config.fork_guard == :raise && !guard.violations.empty?
|
|
80
|
+
|
|
81
|
+
guard.violations.each { |violation| logger.warn(violation.message) } if config.fork_guard == :warn
|
|
82
|
+
end
|
|
83
|
+
@stdout.sync = true if @stdout.respond_to?(:sync=)
|
|
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
|
|
90
|
+
|
|
91
|
+
ForkGuard.deactivate
|
|
92
|
+
config.preload! if config.preload_app?
|
|
93
|
+
return Worker::Runner.new(index: 0, config: config, logger: logger, owner_channel: @owner_channel).run
|
|
94
|
+
else
|
|
95
|
+
config.preload! if config.preload_app?
|
|
96
|
+
router = Router.new(controllers: config.controllers, strict: config.strict_routes, logger: logger)
|
|
97
|
+
end
|
|
48
98
|
if command == "routes"
|
|
49
99
|
router.routes.each_value do |route|
|
|
50
100
|
@stdout.puts "#{route.full_name} #{route.kind} #{route.controller}##{route.action}"
|
|
51
101
|
end
|
|
52
|
-
else
|
|
53
|
-
start(config, router, logger)
|
|
54
102
|
end
|
|
55
103
|
0
|
|
56
|
-
rescue ConfigurationError,
|
|
104
|
+
rescue ConfigurationError, ForkGuard::Violation, ArgumentError, Errno::ENOENT, SyntaxError, LoadError, RuntimeError => e
|
|
57
105
|
@stderr.puts "gritz: #{e.message}"
|
|
58
106
|
1
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
private
|
|
62
|
-
|
|
63
|
-
def start(config, router, logger)
|
|
64
|
-
@stdout.sync = true if @stdout.respond_to?(:sync=)
|
|
65
|
-
# Load the adapter only when starting; route inspection stays transport-independent.
|
|
66
|
-
require "gritz/native"
|
|
67
|
-
dispatcher = Dispatcher.new(router: router, middleware: config.middleware, logger: logger)
|
|
68
|
-
adapter = Transport::Native.new(config: config, dispatcher: dispatcher, logger: logger)
|
|
69
|
-
signals = []
|
|
70
|
-
previous = %w[TERM INT QUIT].to_h { |name| [name, Signal.trap(name) { signals << name }] }
|
|
71
|
-
config.run_hooks(:on_worker_boot, 0)
|
|
72
|
-
port = adapter.bind(config.bind)
|
|
73
|
-
adapter.start
|
|
74
|
-
logger.info("Gritz #{Core::VERSION} listening on #{config.bind.sub(/:\d+\z/, ":#{port}")}")
|
|
75
|
-
while signals.empty?
|
|
76
|
-
sleep 0.05
|
|
77
|
-
raise ConfigurationError, "gRPC server stopped unexpectedly" unless adapter.running?
|
|
78
|
-
end
|
|
79
|
-
signals.shift == "QUIT" ? adapter.kill : adapter.stop(deadline: Time.now + config.shutdown_timeout)
|
|
80
107
|
ensure
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
config.run_hooks(:on_worker_shutdown, 0)
|
|
108
|
+
ForkGuard.deactivate if guard
|
|
109
|
+
@owner_channel&.close
|
|
84
110
|
end
|
|
85
111
|
end
|
|
86
112
|
end
|
data/lib/gritz/configuration.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module Gritz
|
|
6
|
-
#
|
|
6
|
+
# Validated startup settings for single-process and supervised servers.
|
|
7
7
|
# @api public
|
|
8
8
|
class Configuration
|
|
9
9
|
DEFAULTS = {
|
|
@@ -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],
|
|
@@ -46,6 +47,7 @@ module Gritz
|
|
|
46
47
|
DSL.new(config).evaluate(path) if path
|
|
47
48
|
env.each do |key, value|
|
|
48
49
|
next unless key.start_with?("GRITZ_")
|
|
50
|
+
next if key == "GRITZ_RELEASE" # Dependency selection in the release workflow, not a runtime setting.
|
|
49
51
|
|
|
50
52
|
name = key.delete_prefix("GRITZ_").downcase.to_sym
|
|
51
53
|
raise ConfigurationError, "Unknown environment setting #{key}" unless DEFAULTS.key?(name)
|
|
@@ -62,7 +64,7 @@ module Gritz
|
|
|
62
64
|
when Integer then Integer(value, 10)
|
|
63
65
|
when Float then Float(value)
|
|
64
66
|
when Symbol then value.to_sym
|
|
65
|
-
when Hash then JSON.parse(value, symbolize_names: true)
|
|
67
|
+
when Hash, Array then JSON.parse(value, symbolize_names: true)
|
|
66
68
|
when true, false
|
|
67
69
|
return true if %w[true 1].include?(value)
|
|
68
70
|
return false if %w[false 0].include?(value)
|
|
@@ -85,7 +87,7 @@ module Gritz
|
|
|
85
87
|
end
|
|
86
88
|
raise ConfigurationError, "Invalid #{name}: #{value.inspect}" unless valid
|
|
87
89
|
end
|
|
88
|
-
%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|
|
|
89
91
|
raise ConfigurationError, "#{name} must be positive" unless public_send(name).positive?
|
|
90
92
|
end
|
|
91
93
|
ENUMS.each do |name, allowed|
|
|
@@ -97,6 +99,12 @@ module Gritz
|
|
|
97
99
|
raise ConfigurationError, "controllers must be an Array of classes" unless controllers.is_a?(Array) && controllers.all?(Class)
|
|
98
100
|
raise ConfigurationError, "middleware must be a Stack" unless middleware.is_a?(Middleware::Stack)
|
|
99
101
|
raise ConfigurationError, "preload_app must be boolean" unless [true, false].include?(preload_app)
|
|
102
|
+
unless log_redact.all? { |name| name.is_a?(String) || name.is_a?(Symbol) }
|
|
103
|
+
raise ConfigurationError, "log_redact must contain metadata names"
|
|
104
|
+
end
|
|
105
|
+
unless health_checks.all? { |name, check| (name.is_a?(String) || name.is_a?(Symbol)) && check.respond_to?(:call) }
|
|
106
|
+
raise ConfigurationError, "health_checks must contain named callbacks"
|
|
107
|
+
end
|
|
100
108
|
|
|
101
109
|
self
|
|
102
110
|
end
|
|
@@ -104,22 +112,30 @@ module Gritz
|
|
|
104
112
|
def preload_app? = preload_app
|
|
105
113
|
|
|
106
114
|
# Fail before allocating transport resources for unsupported release features.
|
|
107
|
-
def
|
|
115
|
+
def validate_runtime!
|
|
108
116
|
validate!
|
|
109
|
-
raise ConfigurationError, "
|
|
110
|
-
raise ConfigurationError, "
|
|
111
|
-
raise ConfigurationError, "
|
|
112
|
-
raise ConfigurationError, "
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
raise ConfigurationError, "This release supports transport :native" unless transport == :native
|
|
118
|
+
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
|
|
120
|
+
raise ConfigurationError, "This release supports phased_restart_surge 1" unless phased_restart_surge == 1
|
|
121
|
+
|
|
122
|
+
unless worker_recycle.empty?
|
|
123
|
+
raise ConfigurationError, "worker_recycle requires workers > 0" unless workers.positive?
|
|
124
|
+
raise ConfigurationError, "worker_recycle requires a fixed bind port" if bind.end_with?(":0")
|
|
125
|
+
end
|
|
126
|
+
raise ConfigurationError, "grpc_fork_support requires workers > 0" if workers.zero? && fork_mode != :clean
|
|
118
127
|
raise ConfigurationError, "at least one controller must be registered" if controllers.empty?
|
|
119
128
|
|
|
120
129
|
self
|
|
121
130
|
end
|
|
122
131
|
|
|
132
|
+
def validate_single_process!
|
|
133
|
+
validate_runtime!
|
|
134
|
+
raise ConfigurationError, "Testing::Server requires workers 0; use Testing::Cluster for supervised servers" unless workers.zero?
|
|
135
|
+
|
|
136
|
+
self
|
|
137
|
+
end
|
|
138
|
+
|
|
123
139
|
def add_preloader(&block)
|
|
124
140
|
@preload_app = true
|
|
125
141
|
@preloaders << block if block
|
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
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gritz
|
|
4
|
+
# Detects transport resource constructors in the master before workers fork.
|
|
5
|
+
# @api public
|
|
6
|
+
class ForkGuard
|
|
7
|
+
class Violation < StandardError
|
|
8
|
+
attr_reader :klass, :locations
|
|
9
|
+
|
|
10
|
+
def initialize(klass, locations)
|
|
11
|
+
@klass = klass
|
|
12
|
+
@locations = locations
|
|
13
|
+
super("gRPC object was created in the master process before fork.\n " \
|
|
14
|
+
"#{klass}.new\n#{locations.map { |location| " from #{location}" }.join("\n")}\n" \
|
|
15
|
+
"Fix: create it in an on_worker_boot hook.")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module ConstructorHook
|
|
20
|
+
def new(...)
|
|
21
|
+
Gritz::ForkGuard.check!(self)
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :mode, :logger, :master_pid, :violations
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
attr_reader :current
|
|
30
|
+
|
|
31
|
+
def activate(mode: :raise, logger: nil, master_pid: Process.pid)
|
|
32
|
+
@current = new(mode:, logger:, master_pid:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def deactivate
|
|
36
|
+
@current = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def install(klass)
|
|
40
|
+
klass.singleton_class.prepend(ConstructorHook) unless klass.singleton_class.ancestors.include?(ConstructorHook)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def master?
|
|
44
|
+
@current&.master? || false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def check!(klass)
|
|
48
|
+
guard = @current
|
|
49
|
+
return unless guard && guard.mode != :off && guard.master?
|
|
50
|
+
|
|
51
|
+
locations = caller_locations(1, 8).reject { |location| location.path == __FILE__ }
|
|
52
|
+
violation = Violation.new(klass, locations)
|
|
53
|
+
guard.violations << violation
|
|
54
|
+
case guard.mode
|
|
55
|
+
when :raise then raise violation
|
|
56
|
+
when :warn
|
|
57
|
+
guard.logger ? guard.logger.warn(violation.message) : Kernel.warn(violation.message)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(mode:, logger:, master_pid:)
|
|
63
|
+
raise ArgumentError, "fork guard mode must be :raise, :warn, :off, or :record" unless %i[raise warn off record].include?(mode)
|
|
64
|
+
|
|
65
|
+
@mode = mode
|
|
66
|
+
@logger = logger
|
|
67
|
+
@master_pid = master_pid
|
|
68
|
+
@violations = []
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def master?
|
|
72
|
+
Process.pid == @master_pid
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gritz
|
|
4
|
+
module Metrics
|
|
5
|
+
# Master-owned totals survive worker retirement; only replay state is forgotten.
|
|
6
|
+
# @api private
|
|
7
|
+
class Aggregator
|
|
8
|
+
def initialize
|
|
9
|
+
@lock = Mutex.new
|
|
10
|
+
@rpc = {}
|
|
11
|
+
@sequences = {}
|
|
12
|
+
@rejected = 0
|
|
13
|
+
@restarts = Hash.new(0)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def apply(worker, envelope)
|
|
17
|
+
unless envelope.is_a?(Hash) && envelope[:seq].is_a?(Integer) && envelope[:seq].positive?
|
|
18
|
+
raise ConfigurationError, "Invalid metric sequence"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@lock.synchronize do
|
|
22
|
+
sequence = envelope[:seq]
|
|
23
|
+
previous = @sequences.fetch(worker, 0)
|
|
24
|
+
return false if sequence <= previous
|
|
25
|
+
raise ConfigurationError, "Metric sequence gap: expected #{previous + 1}, got #{sequence}" unless sequence == previous + 1
|
|
26
|
+
|
|
27
|
+
delta = envelope[:delta]
|
|
28
|
+
validate_delta!(delta)
|
|
29
|
+
delta[:rpc].each do |row|
|
|
30
|
+
labels = row.slice(:service, :method, :code)
|
|
31
|
+
aggregate = @rpc[labels.values]
|
|
32
|
+
unless aggregate
|
|
33
|
+
aggregate = Recorder.empty_row(**labels)
|
|
34
|
+
@rpc[aggregate.values_at(:service, :method, :code).freeze] = aggregate
|
|
35
|
+
end
|
|
36
|
+
Recorder::SCALARS.each { |field| aggregate[field] += row[field] }
|
|
37
|
+
Recorder::BUCKET_FIELDS.each do |field|
|
|
38
|
+
row[field].each_with_index { |count, index| aggregate[field][index] += count }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
@rejected += delta[:rejected]
|
|
42
|
+
@sequences[worker] = sequence
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def forget(worker) = @lock.synchronize { @sequences.delete(worker) }
|
|
48
|
+
|
|
49
|
+
def record_restart(reason:)
|
|
50
|
+
@lock.synchronize { @restarts[reason.to_s] += 1 }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render(workers: [])
|
|
54
|
+
rows = workers.map { |worker| worker.is_a?(Hash) ? worker : worker.stats.merge(pid: worker.pid, index: worker.index, state: worker.state) }
|
|
55
|
+
@lock.synchronize do
|
|
56
|
+
output = +""
|
|
57
|
+
histogram(output, "rpc_server_duration_seconds", Recorder::DURATION_BUCKETS, :duration_buckets, :duration_sum)
|
|
58
|
+
histogram(output, "rpc_server_requests_per_rpc", Recorder::MESSAGE_BUCKETS, :request_buckets, :request_sum)
|
|
59
|
+
histogram(output, "rpc_server_responses_per_rpc", Recorder::MESSAGE_BUCKETS, :response_buckets, :response_sum)
|
|
60
|
+
output << "# TYPE gritz_rejected_total counter\ngritz_rejected_total #{@rejected}\n"
|
|
61
|
+
output << "# TYPE gritz_worker_restarts_total counter\n"
|
|
62
|
+
@restarts.sort.each { |reason, count| output << "gritz_worker_restarts_total{reason=\"#{escape(reason)}\"} #{count}\n" }
|
|
63
|
+
output << "# TYPE gritz_workers gauge\n"
|
|
64
|
+
Supervisor::WorkerHandle::STATES.each { |state| output << "gritz_workers{state=\"#{state}\"} #{rows.count { |row| row[:state] == state }}\n" }
|
|
65
|
+
serving = rows.select { |row| %w[ready draining].include?(row[:state]) }
|
|
66
|
+
output << "# TYPE gritz_threadpool_busy gauge\ngritz_threadpool_busy #{serving.sum { |row| row[:busy_threads] || row[:busy] || 0 }}\n"
|
|
67
|
+
output << "# TYPE gritz_threadpool_capacity gauge\ngritz_threadpool_capacity #{serving.sum { |row| row[:capacity] || 0 }}\n"
|
|
68
|
+
output << "# TYPE gritz_worker_pss_bytes gauge\n"
|
|
69
|
+
rows.each do |row|
|
|
70
|
+
next unless row[:pss_bytes]
|
|
71
|
+
|
|
72
|
+
output << "gritz_worker_pss_bytes{worker=\"#{row[:index]}\",pid=\"#{row[:pid]}\"} #{row[:pss_bytes]}\n"
|
|
73
|
+
end
|
|
74
|
+
output
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def validate_delta!(delta)
|
|
81
|
+
valid = delta.is_a?(Hash) && delta[:rpc].is_a?(Array) && nonnegative_integer?(delta[:rejected])
|
|
82
|
+
raise ConfigurationError, "Invalid metric delta" unless valid
|
|
83
|
+
|
|
84
|
+
delta[:rpc].each do |row|
|
|
85
|
+
valid = row.is_a?(Hash) && %i[service method].all? { |key| row[key].is_a?(String) && !row[key].empty? } &&
|
|
86
|
+
row[:code].is_a?(Integer) && row[:code].between?(0, 16) &&
|
|
87
|
+
%i[count request_sum response_sum].all? { |key| nonnegative_integer?(row[key]) } &&
|
|
88
|
+
row[:duration_sum].is_a?(Numeric) && row[:duration_sum].real? && row[:duration_sum].to_f.finite? && row[:duration_sum] >= 0
|
|
89
|
+
raise ConfigurationError, "Invalid RPC metric row" unless valid
|
|
90
|
+
|
|
91
|
+
Recorder::BUCKET_FIELDS.each do |field|
|
|
92
|
+
size = field == :duration_buckets ? Recorder::DURATION_BUCKETS.size : Recorder::MESSAGE_BUCKETS.size
|
|
93
|
+
buckets = row[field]
|
|
94
|
+
unless buckets.is_a?(Array) && buckets.size == size && buckets.all? { |count| nonnegative_integer?(count) } && buckets.sum == row[:count]
|
|
95
|
+
raise ConfigurationError, "Invalid RPC metric histogram"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def nonnegative_integer?(value) = value.is_a?(Integer) && value >= 0
|
|
102
|
+
|
|
103
|
+
def histogram(output, name, bounds, bucket_field, sum_field)
|
|
104
|
+
output << "# TYPE #{name} histogram\n"
|
|
105
|
+
@rpc.sort_by { |key, _row| key }.each do |_key, row|
|
|
106
|
+
labels = "rpc_service=\"#{escape(row[:service])}\",rpc_method=\"#{escape(row[:method])}\",rpc_grpc_status_code=\"#{row[:code]}\""
|
|
107
|
+
total = 0
|
|
108
|
+
bounds.each_with_index do |upper, index|
|
|
109
|
+
total += row[bucket_field][index]
|
|
110
|
+
output << "#{name}_bucket{#{labels},le=\"#{upper.infinite? ? '+Inf' : upper}\"} #{total}\n"
|
|
111
|
+
end
|
|
112
|
+
output << "#{name}_sum{#{labels}} #{row[sum_field]}\n#{name}_count{#{labels}} #{row[:count]}\n"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def escape(value) = value.gsub(/[\\"\n]/) { |character| { "\\" => "\\\\", '"' => '\\"', "\n" => "\\n" }.fetch(character) }
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|