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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 023ead58f761660de79d7168434d85be2f92e583073fab043b18b18fd14035c5
4
- data.tar.gz: f319a9c5f6a190fad09e78667072ee22a5994dfe1f5c2e4f12287f3213865e1a
3
+ metadata.gz: 600c0b33c1d7f7008866ee20d789504559715612886cd3f8dc33413e5192cb36
4
+ data.tar.gz: c3797690d572a4d29f5275946b16f84b5717fb19e4f31fd6a20217ece840eb92
5
5
  SHA512:
6
- metadata.gz: d531f928a935dd66d9a3459919fff84263309e6821da90332a841170b1bc268e2ccc711394a5873590611883136d44987d0d059794520d1335d788ad37815660
7
- data.tar.gz: '08b50b33dcbb22fdde34c4052f2c15e82cfb1562bc83f450966f98879e4f0d8bc64cf50f8bb3517fae12ebfe4ac4841928f5ab5f5032a96eabd006711f8e44dc'
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
- def initialize(stdout: $stdout, stderr: $stderr, env: ENV, status_io: nil)
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
- return Supervisor::Master.new(config, logger: logger, status_io: @status_io).run if config.workers.positive?
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
@@ -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
@@ -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
- raise ConfigurationError, "TLS is planned for v0.3" unless tls.empty?
113
- raise ConfigurationError, "worker_recycle is planned for v0.3" unless worker_recycle.empty?
114
- raise ConfigurationError, "Health checks are planned for v0.3" unless health_checks.empty?
115
- raise ConfigurationError, "This release supports log_format :json" unless log_format == :json
116
- raise ConfigurationError, "This release reserves metrics_backend :pipe; metrics export is planned for v0.3" unless metrics_backend == :pipe
122
+ unless metrics_backend == :pipe || (metrics_backend == :otlp && metrics_recorder_factory)
123
+ raise ConfigurationError, "metrics_backend requires an installed recorder (:pipe is built in)"
124
+ end
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
@@ -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.2.0"
5
+ VERSION = "0.4.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
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