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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d69bbe23da7b5ec3c973bdbe3a2c2c1d1596b6632695f446ae6fb242d0d1f94
4
- data.tar.gz: 89929d525c1709e164057aa7d684317db596734395371cf942019832cf7189bd
3
+ metadata.gz: 03bd5114b9267c0bc0b5daa80d14433d4941c753321338fc5b83b2281f4d6bf1
4
+ data.tar.gz: 67fabc1d94c5cca35149679a2e582c983a88122a5299638d05c639387ea48fa4
5
5
  SHA512:
6
- metadata.gz: c4c61c0a88eedb466c3f556f9cd9861468f496b0ba6303be58133d58c6761d8d6fa6a1a9c4b51e7dd6bec168b8207aa8b82562d096f0ead5f8745e351a67c790
7
- data.tar.gz: 5e7d9a717648f45c0f3774f3de35511608d6b6fa7fc5fc6f6be61fbf4d7b874c1cfafdd348b9441e0f37a528a30d341f3eddfa45aa50760255a42f7b644a58cf
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 and never loads a transport by itself.
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
- # Command line entry point for the single-process server and route listing.
9
+ # Starts a server, lists routes, or checks configuration and fork safety.
8
10
  # @api public
9
11
  class CLI
10
- def initialize(stdout: $stdout, stderr: $stderr, env: ENV)
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
- router = Router.new(controllers: config.controllers, strict: config.strict_routes, logger: logger)
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, OptionParser::ParseError, ArgumentError, Errno::ENOENT, SyntaxError, LoadError => e
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
- previous&.each { |name, handler| Signal.trap(name, handler) }
82
- adapter&.kill if adapter&.running?
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
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  module Gritz
6
- # Startup settings. Process-related settings are reserved for the supervisor.
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 validate_single_process!
115
+ def validate_runtime!
108
116
  validate!
109
- raise ConfigurationError, "v0.1 supports workers 0; the supervisor is planned for v0.2" unless workers.zero?
110
- raise ConfigurationError, "v0.1 supports transport :native" unless transport == :native
111
- raise ConfigurationError, "v0.1 supports listener_strategy :reuseport" unless listener_strategy == :reuseport
112
- raise ConfigurationError, "TLS is planned for v0.3" unless tls.empty?
113
- raise ConfigurationError, "worker_recycle requires the supervisor" unless worker_recycle.empty?
114
- raise ConfigurationError, "Health checks are planned for v0.3" unless health_checks.empty?
115
- raise ConfigurationError, "v0.1 supports log_format :json" unless log_format == :json
116
- raise ConfigurationError, "v0.1 reserves metrics_backend :pipe; metrics export is planned for v0.3" unless metrics_backend == :pipe
117
- raise ConfigurationError, "grpc_fork_support requires the supervisor" unless fork_mode == :clean
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
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gritz
4
4
  module Core
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -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? ? result : context.validate_response!(result)
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