gritz-native 0.2.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: 4e8049bb49561c6146900ca5fdf2f40177d4da3352b488c0c090b834f28a76bd
4
- data.tar.gz: f55456efb1b318a0079ebf027b77f41d63413ca2abc723319804fe07ee403165
3
+ metadata.gz: 11284795837121fb007d3158c2fa704281946741d262e2101cf0ab3b134183e5
4
+ data.tar.gz: b1c1a7c414f1b3f435af88183f8da23dd7aaabcff4591355dbfdd138deeffcb0
5
5
  SHA512:
6
- metadata.gz: 7a98685d7f3d10fefba9d156e0d5f182aed3a497e3886534e082449629e6420422ef614fbe8e0b0b793ae6ada7cdcd03e357ec58db0bf3826d7627615b32cd09
7
- data.tar.gz: d5051b7e5a6739eac2d3be2b53316b0def376af8bfbe91c77958c1d554e1e08842d3a6a298f359a735ed736e3310e430e8c842ea00650900af4dd9f3be245173
6
+ metadata.gz: 832837820eb0ecb86bef7fe3cf84514f52a22b92933fb08bf9fd8d3cb48fee690585ba42c853bee984bdd801fb93c7fdaa96e5a873ffee93b976b417f99f08b0
7
+ data.tar.gz: f7b4a6ba6ba8ad367db6d6d486fc041471f73a7fa72663ed90ee71ed1445122d287c92c2ceaef986b9b7572445f3c03960e4a65f01af4588be904b2c67d251a8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Serve TLS and mTLS connections and expose authenticated client certificates to handlers.
6
+ - Provide standard gRPC Health Check and Watch methods linked to application health and draining.
7
+ - Finish accepted RPCs during graceful shutdown and phased worker replacement.
8
+ - Report actual native thread-pool usage, including active Health watches.
9
+
3
10
  ## 0.2.0
4
11
 
5
12
  - Detect native gRPC constructors in the master before fork.
data/README.md CHANGED
@@ -17,7 +17,7 @@ Gritz::Testing::Server.start(controllers: [GreeterController]) do |server|
17
17
  end
18
18
  ```
19
19
 
20
- The helper opens an ephemeral port and stops the server when the block exits. The helper runs a single process. Supervised native servers also support forked workers on Linux. This release uses insecure sockets; use a trusted network or a TLS-terminating proxy. See the [framework documentation](https://github.com/gritzrpc/gritz) for configuration and limitations.
20
+ The helper opens an ephemeral port and stops the server when the block exits. The helper runs a single process. Supervised native servers support forked workers on Linux. TLS and required client certificate verification are configured with `tls cert:, key:, client_ca:`. Standard gRPC Health Check/Watch tracks application checks and draining. See the [Kubernetes guide](docs/guides/kubernetes.md) and [framework configuration](https://github.com/gritzrpc/gritz-core/blob/main/docs/guides/configuration.md).
21
21
 
22
22
  ## Development
23
23
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gritz
4
4
  module Native
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/gritz/native.rb CHANGED
@@ -11,4 +11,5 @@ require_relative "transport/native"
11
11
  require_relative "transport/native/call"
12
12
  require_relative "transport/native/bridge"
13
13
  require_relative "transport/native/server"
14
+ require_relative "transport/native/health"
14
15
  require_relative "testing/server"
@@ -46,6 +46,12 @@ module Gritz
46
46
  @port = transport.bind
47
47
  @address = @config.bind.sub(/:\d+\z/, ":#{port}")
48
48
  transport.start
49
+ unless @config.health_checks.empty?
50
+ @health_stop = Queue.new
51
+ @health_thread = Thread.new do
52
+ transport.refresh_health until @health_stop.pop(timeout: @config.status_interval)
53
+ end
54
+ end
49
55
  self
50
56
  rescue StandardError
51
57
  stop
@@ -58,6 +64,10 @@ module Gritz
58
64
 
59
65
  @stopped = true
60
66
  begin
67
+ @health_stop&.push(true)
68
+ if @health_thread && !@health_thread.join(@config.shutdown_timeout)
69
+ @health_thread.kill.join
70
+ end
61
71
  transport&.stop(deadline: Time.now + @config.shutdown_timeout)
62
72
  ensure
63
73
  @config.run_hooks(:on_worker_shutdown, 0) if @lifecycle_started
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "grpc/health/v1/health_services_pb"
4
+
5
+ module Gritz
6
+ module Transport
7
+ class Native
8
+ # Standard health RPCs share the server pool; each open Watch uses one pool thread.
9
+ # @api private
10
+ class Health < Grpc::Health::V1::Health::Service
11
+ # Ruby's server call view cannot observe cancellation until the call ends.
12
+ # Watch needs a dedicated close observer, with at most one per occupied pool thread.
13
+ class WatchDescriptor < GRPC::RpcDesc
14
+ def handle_server_streamer(active_call, method, interception)
15
+ request = active_call.read_unary_request
16
+ call = active_call.single_req_view
17
+ raw = active_call.instance_variable_get(:@call)
18
+ closed = false
19
+ observer = Thread.new do
20
+ raw.run_batch(GRPC::Core::CallOps::RECV_CLOSE_ON_SERVER => nil)
21
+ rescue GRPC::Core::CallError
22
+ nil
23
+ ensure
24
+ closed = true
25
+ method.receiver.wake
26
+ end
27
+ call.define_singleton_method(:cancelled?) { closed }
28
+ status = begin
29
+ interception.intercept!(:server_streamer, method:, call:, request:) do
30
+ method.call(request, call).each { |reply| active_call.remote_send(reply) }
31
+ end
32
+ ::Struct::Status.new(GRPC::Core::StatusCodes::OK, "OK", active_call.output_metadata)
33
+ rescue GRPC::BadStatus => e
34
+ ::Struct::Status.new(e.code, e.details, e.metadata)
35
+ rescue GRPC::Core::OutOfTime
36
+ ::Struct::Status.new(GRPC::Core::StatusCodes::DEADLINE_EXCEEDED, "late", {})
37
+ rescue StandardError, NotImplementedError
38
+ ::Struct::Status.new(GRPC::Core::StatusCodes::UNKNOWN, "Health handler failed", {})
39
+ end
40
+ active_call.send_initial_metadata
41
+ raw.run_batch(GRPC::Core::CallOps::SEND_STATUS_FROM_SERVER => status)
42
+ observer.join
43
+ rescue GRPC::Core::CallError
44
+ nil
45
+ ensure
46
+ if observer
47
+ raw.cancel if observer.alive?
48
+ observer.join
49
+ # Finish only after the native close operation has released its call resources.
50
+ active_call.__send__(:set_output_stream_done)
51
+ end
52
+ end
53
+ end
54
+
55
+ rpc_descs[:Watch] = WatchDescriptor.new(*rpc_descs.fetch(:Watch).to_a)
56
+
57
+ def initialize(services)
58
+ super()
59
+ @services = ["", *services].to_set
60
+ @mutex = Mutex.new
61
+ @changed = ConditionVariable.new
62
+ @serving = @closed = false
63
+ end
64
+
65
+ def update(ready)
66
+ @mutex.synchronize do
67
+ serving = ready && !@closed
68
+ @changed.broadcast if serving != @serving
69
+ @serving = serving
70
+ end
71
+ end
72
+
73
+ def drain!
74
+ @mutex.synchronize do
75
+ @serving = false
76
+ @closed = true
77
+ @changed.broadcast
78
+ end
79
+ end
80
+
81
+ def wake = @mutex.synchronize { @changed.broadcast }
82
+
83
+ def check(request, _call)
84
+ raise GRPC::NotFound, "unknown service" unless @services.include?(request.service)
85
+
86
+ @mutex.synchronize { Grpc::Health::V1::HealthCheckResponse.new(status: status_for(request.service)) }
87
+ end
88
+
89
+ def watch(request, call)
90
+ Enumerator.new do |replies|
91
+ previous = nil
92
+ loop do
93
+ status, closed = @mutex.synchronize do
94
+ @changed.wait(@mutex, 0.05) while !@closed && !call.cancelled? && status_for(request.service) == previous
95
+ [status_for(request.service), @closed]
96
+ end
97
+ break if call.cancelled?
98
+
99
+ if status != previous
100
+ replies << Grpc::Health::V1::HealthCheckResponse.new(status:)
101
+ previous = status
102
+ end
103
+ break if closed
104
+ end
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def status_for(service)
111
+ return :SERVICE_UNKNOWN unless @services.include?(service)
112
+
113
+ @serving ? :SERVING : :NOT_SERVING
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -17,6 +17,14 @@ module Gritz
17
17
  result
18
18
  end
19
19
 
20
+ def busy_threads
21
+ # grpc 1.83 has no public busy count; its idle queues cover decode and
22
+ # response writes as well as handlers. Live threads also handle pre-start/stop.
23
+ workers = @pool.instance_variable_get(:@workers)
24
+ ready = @pool.instance_variable_get(:@ready_workers)
25
+ [workers.count(&:alive?) - ready.size, 0].max
26
+ end
27
+
20
28
  def stop(deadline:)
21
29
  # grpc 1.83's stop takes no deadline and reads @poll_period instead.
22
30
  # Set its relative grace from the caller's absolute shutdown deadline.
@@ -24,6 +32,37 @@ module Gritz
24
32
  super()
25
33
  end
26
34
 
35
+ # RpcServer's loop checks running_state before every RequestCall. Its stop
36
+ # holds that mutex during shutdown_and_notify, so an accepted RPC can leave
37
+ # the loop blocked while C-core still has unmatched calls. Keep consuming
38
+ # until C-core closes RequestCall instead of stopping on the Ruby state.
39
+ def loop_handle_server_calls
40
+ raise "not started" if running_state == :not_started
41
+
42
+ loop do
43
+ rpc = @server.request_call
44
+ break if rpc && rpc.call.nil?
45
+
46
+ active_call = new_active_server_call(rpc)
47
+ next unless active_call
48
+
49
+ @pool.schedule(active_call) do |pair|
50
+ call, method = pair
51
+ rpc_descs[method].run_server_method(call, rpc_handlers[method], @interceptors.build_context)
52
+ rescue StandardError
53
+ call.send_status(GRPC::Core::StatusCodes::INTERNAL, "Server handler failed")
54
+ end
55
+ rescue GRPC::Core::CallError, RuntimeError => e
56
+ break unless running_state == :running
57
+
58
+ GRPC.logger.warn("server call failed: #{e}")
59
+ end
60
+ @run_mutex.synchronize do
61
+ transition_running_state(:stopped)
62
+ @server.close
63
+ end
64
+ end
65
+
27
66
  def close_unstarted
28
67
  return unless running_state == :not_started
29
68
 
@@ -8,7 +8,7 @@ module Gritz
8
8
  # Runs generated services through Gritz's transport-independent dispatcher.
9
9
  # @api public
10
10
  class Native
11
- def self.capabilities = Set[:unary, :client_streaming, :server_streaming, :bidi, :reuseport].freeze
11
+ def self.capabilities = Set[:unary, :client_streaming, :server_streaming, :bidi, :reuseport, :health, :tls, :mtls].freeze
12
12
 
13
13
  def self.prefork
14
14
  GRPC.prefork
@@ -32,9 +32,9 @@ module Gritz
32
32
  # @return [Integer] the port selected by C-core
33
33
  def bind(listener_spec = @config.bind)
34
34
  raise ArgumentError, "transport is already bound" if @server
35
- raise ArgumentError, "TLS is not available in this version" unless @config.tls.empty?
36
35
  raise ArgumentError, "at least one controller must be registered" if @dispatcher.router.routes.empty?
37
36
 
37
+ credentials = server_credentials
38
38
  created_server = @server = Server.new(
39
39
  pool_size: @config.threads,
40
40
  # grpc 1.83 accepts but ignores this value: busy pools reject immediately.
@@ -47,7 +47,9 @@ module Gritz
47
47
  @dispatcher.router.routes.values.group_by(&:service_class).each do |service_class, descriptors|
48
48
  @server.handle(Bridge.build(service_class, descriptors, self))
49
49
  end
50
- @port = @server.add_http2_port(listener_spec, :this_port_is_insecure)
50
+ @health = Health.new(@dispatcher.router.routes.values.map(&:service).uniq)
51
+ @server.handle(@health)
52
+ @port = @server.add_http2_port(listener_spec, credentials)
51
53
  raise ArgumentError, "could not bind #{listener_spec}" unless @port.positive?
52
54
 
53
55
  @port
@@ -62,7 +64,10 @@ module Gritz
62
64
  raise ArgumentError, "transport is already started" if @thread
63
65
 
64
66
  @thread = Thread.new { @server.run }
65
- return self if @server.wait_till_running(5)
67
+ if @server.wait_till_running(5)
68
+ refresh_health
69
+ return self
70
+ end
66
71
 
67
72
  @thread.value unless @thread.alive?
68
73
  kill
@@ -76,6 +81,7 @@ module Gritz
76
81
  def running? = @thread&.alive? && @server.running?
77
82
 
78
83
  def stop(deadline:)
84
+ drain!
79
85
  unless @thread
80
86
  @server&.close_unstarted
81
87
  @server = nil
@@ -88,11 +94,35 @@ module Gritz
88
94
 
89
95
  def kill = stop(deadline: Time.now)
90
96
 
97
+ def update_health(ready:, checks: {})
98
+ healthy = ready && checks.values.all? && !@draining
99
+ @health&.update(healthy)
100
+ healthy
101
+ end
102
+
103
+ def drain!
104
+ @draining = true
105
+ @health&.drain!
106
+ self
107
+ end
108
+
109
+ # @api private
110
+ def refresh_health
111
+ checks = @config.health_checks.transform_values do |check|
112
+ check.call ? true : false
113
+ rescue StandardError => e
114
+ @logger.warn("Health check failed (#{e.class})")
115
+ false
116
+ end
117
+ update_health(ready: running?, checks:)
118
+ end
119
+
91
120
  def stats
121
+ busy = @server&.busy_threads || 0
92
122
  @lock.synchronize do
93
123
  oldest = @inflight.values.min
94
124
  {
95
- inflight: @inflight.size, busy: @inflight.size, capacity: @config.threads,
125
+ inflight: @inflight.size, busy: busy, capacity: @config.threads,
96
126
  rejected_total: @rejected_total, requests_total: @requests_total,
97
127
  oldest_inflight_age: oldest ? Process.clock_gettime(Process::CLOCK_MONOTONIC) - oldest : 0
98
128
  }
@@ -114,6 +144,14 @@ module Gritz
114
144
 
115
145
  private
116
146
 
147
+ def server_credentials
148
+ return :this_port_is_insecure if @config.tls.empty?
149
+
150
+ ca = @config.tls[:client_ca] && File.read(@config.tls[:client_ca])
151
+ GRPC::Core::ServerCredentials.new(ca, [{ private_key: File.read(@config.tls.fetch(:key)),
152
+ cert_chain: File.read(@config.tls.fetch(:cert)) }], !ca.nil?)
153
+ end
154
+
117
155
  def status_exception(call, error)
118
156
  metadata = call.trailing_metadata.merge(error.metadata)
119
157
  unless error.details.empty?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gritz-native
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -35,14 +35,14 @@ dependencies:
35
35
  requirements:
36
36
  - - '='
37
37
  - !ruby/object:Gem::Version
38
- version: 0.2.0
38
+ version: 0.3.0
39
39
  type: :runtime
40
40
  prerelease: false
41
41
  version_requirements: !ruby/object:Gem::Requirement
42
42
  requirements:
43
43
  - - '='
44
44
  - !ruby/object:Gem::Version
45
- version: 0.2.0
45
+ version: 0.3.0
46
46
  - !ruby/object:Gem::Dependency
47
47
  name: grpc
48
48
  requirement: !ruby/object:Gem::Requirement
@@ -81,6 +81,7 @@ files:
81
81
  - lib/gritz/transport/native.rb
82
82
  - lib/gritz/transport/native/bridge.rb
83
83
  - lib/gritz/transport/native/call.rb
84
+ - lib/gritz/transport/native/health.rb
84
85
  - lib/gritz/transport/native/server.rb
85
86
  homepage: https://github.com/gritzrpc/gritz-native
86
87
  licenses: