gritz-core 0.3.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: 03bd5114b9267c0bc0b5daa80d14433d4941c753321338fc5b83b2281f4d6bf1
4
- data.tar.gz: 67fabc1d94c5cca35149679a2e582c983a88122a5299638d05c639387ea48fa4
3
+ metadata.gz: 600c0b33c1d7f7008866ee20d789504559715612886cd3f8dc33413e5192cb36
4
+ data.tar.gz: c3797690d572a4d29f5275946b16f84b5717fb19e4f31fd6a20217ece840eb92
5
5
  SHA512:
6
- metadata.gz: 22ba419be6cd7bf1754eaccc7af3bde09ac0ca9dcac494f5d1932306aa73d595e0861b1c9ff1af9e694195f86d6c19e3103161a721ccd8e3f4c88ae72c93c56d
7
- data.tar.gz: 1986b1cc2c4a32d0baf218b8268b1ced8399931c1a48379f46071f891c09d4c89cfe489fb707683dcf0d4ed239c708b3bfdd36a156efb0696f14d1764a10eeb6
6
+ metadata.gz: f2858b3d8e39c28d309a3acce2255fcc42ec4aba071b2b79735025a840973ffe0016183204eb7e87c67b1a14f11fef48ffa9e6560234cfd5da0866ccd195f68f
7
+ data.tar.gz: 149a97e113197dc7691152d5aa0f7e246adb55cc0bb90a699b122078ea3272cd32787461808b78d7379804c83a487866f503d8eea7f135a5c356c011e40a2209
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 0.3.0
4
10
 
5
11
  - Replace workers one at a time with `USR1` and recycle them by request count, memory or lifetime.
data/README.md CHANGED
@@ -12,6 +12,8 @@ Controller tests can load `gritz/testing/rspec` or `gritz/testing/minitest` and
12
12
 
13
13
  Supervised servers support phased replacement, fresh-interpreter hot reexec, request/memory/lifetime recycling, Admin HTTP probes and process-wide Prometheus totals. The CLI keeps one lifecycle owner across master generations; application controllers and gRPC resources stay in serving processes.
14
14
 
15
+ `Gritz::Client.define` creates lazy, fork-safe client constants with deadline propagation, selected request headers and middleware around complete streams. See the [client guide](docs/guides/clients.md). Optional [gritz-otel](https://github.com/gritzrpc/gritz-otel) adds server/client spans and worker OTLP metrics after fork.
16
+
15
17
  ## Development
16
18
 
17
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
@@ -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
@@ -28,7 +28,7 @@ module Gritz
28
28
  }.freeze
29
29
  HOOKS = %i[before_fork on_worker_boot on_worker_shutdown].freeze
30
30
 
31
- attr_accessor(*DEFAULTS.keys, :controllers, :middleware, :preload_app)
31
+ attr_accessor(*DEFAULTS.keys, :controllers, :middleware, :preload_app, :metrics_recorder_factory)
32
32
  attr_reader :health_checks
33
33
 
34
34
  def initialize
@@ -98,6 +98,9 @@ module Gritz
98
98
  validate_tls!
99
99
  raise ConfigurationError, "controllers must be an Array of classes" unless controllers.is_a?(Array) && controllers.all?(Class)
100
100
  raise ConfigurationError, "middleware must be a Stack" unless middleware.is_a?(Middleware::Stack)
101
+ if metrics_recorder_factory && !metrics_recorder_factory.respond_to?(:call)
102
+ raise ConfigurationError, "metrics recorder factory must be callable"
103
+ end
101
104
  raise ConfigurationError, "preload_app must be boolean" unless [true, false].include?(preload_app)
102
105
  unless log_redact.all? { |name| name.is_a?(String) || name.is_a?(Symbol) }
103
106
  raise ConfigurationError, "log_redact must contain metadata names"
@@ -116,7 +119,9 @@ module Gritz
116
119
  validate!
117
120
  raise ConfigurationError, "This release supports transport :native" unless transport == :native
118
121
  raise ConfigurationError, "This release supports listener_strategy :reuseport" unless listener_strategy == :reuseport
119
- raise ConfigurationError, "This release supports metrics_backend :pipe" 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
120
125
  raise ConfigurationError, "This release supports phased_restart_surge 1" unless phased_restart_surge == 1
121
126
 
122
127
  unless worker_recycle.empty?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gritz
4
4
  module Core
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  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
@@ -56,6 +56,10 @@ module Gritz
56
56
  end
57
57
  end
58
58
 
59
+ # Optional process observations and shutdown for installed worker backends.
60
+ def observe_worker(_status); end
61
+ def close(timeout: nil); end
62
+
59
63
  # The caller retains this detached delta until its status channel accepts the row.
60
64
  def take_delta(max_bytes: Supervisor::StatusChannel::MAX_LINE_BYTES - 4096)
61
65
  raise ArgumentError, "Metric packet budget must be positive" unless max_bytes.is_a?(Integer) && max_bytes.positive?
@@ -7,16 +7,19 @@ module Gritz
7
7
  # Converts application exceptions to safe RPC errors.
8
8
  # @api public
9
9
  class ExceptionMapper
10
- def initialize(app, mappings: {}, expose_errors: false)
10
+ def initialize(app, mappings: {}, expose_errors: false, passthrough_remote_errors: false)
11
11
  @app = app
12
12
  @mappings = mappings
13
13
  @expose_errors = expose_errors
14
+ @passthrough_remote_errors = passthrough_remote_errors
14
15
  end
15
16
 
16
17
  def call(context)
17
18
  @app.call(context)
18
- rescue Gritz::Error
19
- raise
19
+ rescue Gritz::Error => e
20
+ raise unless e.remote? && !@passthrough_remote_errors
21
+
22
+ raise_internal(context, e)
20
23
  rescue StandardError => e
21
24
  mapping = @mappings.find { |klass, _| e.is_a?(klass) }&.last
22
25
  if mapping
@@ -30,9 +33,15 @@ module Gritz
30
33
  raise Errors::InvalidArgument, "invalid record"
31
34
  end
32
35
 
36
+ raise_internal(context, e)
37
+ end
38
+
39
+ private
40
+
41
+ def raise_internal(context, error)
33
42
  error_id = SecureRandom.uuid
34
- context.store[:gritz_error] = { error_id: error_id, error: e.class.name, message: e.message, backtrace: e.backtrace }
35
- raise Errors::Internal.new(@expose_errors ? e.message : "internal error (#{error_id})", metadata: { "error-id" => error_id })
43
+ context.store[:gritz_error] = { error_id: error_id, error: error.class.name, message: error.message, backtrace: error.backtrace }
44
+ raise Errors::Internal.new(@expose_errors ? error.message : "internal error (#{error_id})", metadata: { "error-id" => error_id })
36
45
  end
37
46
  end
38
47
  end
@@ -28,6 +28,7 @@ module Gritz
28
28
  @boot_started = true
29
29
  @config.preload! unless @config.preload_app?
30
30
  @config.run_hooks(:on_worker_boot, @index)
31
+ @recorder = @config.metrics_recorder_factory.call(worker: @index) if @config.metrics_recorder_factory
31
32
  router = Router.new(controllers: @config.controllers, strict: @config.strict_routes, logger: @logger)
32
33
  dispatcher = Dispatcher.new(router:, middleware: @config.middleware, logger: @logger, metrics: @recorder,
33
34
  worker: @index, log_format: @config.log_format, log_redact: @config.log_redact)
@@ -114,6 +115,7 @@ module Gritz
114
115
  busy_threads: stats.fetch(:busy_threads, stats.fetch(:busy, 0)), healthy: state == "ready" && @healthy,
115
116
  checks: @checks || {}, worker_started_at: @born_at)
116
117
  @last_status = row.merge(extra)
118
+ @recorder.observe_worker(@last_status)
117
119
  @status&.write(@owner_channel ? snapshot : @last_status)
118
120
  end
119
121
 
@@ -184,6 +186,7 @@ module Gritz
184
186
  @status.io.wait_writable((deadline - monotonic).clamp(0, 0.01)) until !@status || @status.flush || @status.closed? || monotonic >= deadline
185
187
  ensure
186
188
  begin
189
+ cleanup { @recorder.close(timeout: [(deadline || monotonic) - monotonic, 0].max) }
187
190
  @signals&.close
188
191
  @admin&.close
189
192
  ensure
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gritz-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -121,7 +121,11 @@ files:
121
121
  - LICENSE.txt
122
122
  - README.md
123
123
  - lib/gritz/call.rb
124
+ - lib/gritz/channel_registry.rb
124
125
  - lib/gritz/cli.rb
126
+ - lib/gritz/client.rb
127
+ - lib/gritz/client/call_context.rb
128
+ - lib/gritz/client/invocation.rb
125
129
  - lib/gritz/configuration.rb
126
130
  - lib/gritz/configuration_error.rb
127
131
  - lib/gritz/context.rb