gritz-native 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: 1acc3257753cf7f6cb65db29e2c77692136a2050044b225b2cc73880fe67e9d3
4
- data.tar.gz: 665dcaae2cc563da543b1305337cce4634d6094bfd39863ed09762765bae35c3
3
+ metadata.gz: 11284795837121fb007d3158c2fa704281946741d262e2101cf0ab3b134183e5
4
+ data.tar.gz: b1c1a7c414f1b3f435af88183f8da23dd7aaabcff4591355dbfdd138deeffcb0
5
5
  SHA512:
6
- metadata.gz: 0ee1d520c991ea1b015215651b60a94071e73ccdf130b3e1bbf2982b4b72be90c448d3a2d89db82b37132f170bd6428455428f54d20be68c11f57a8b329be368
7
- data.tar.gz: b1f58698b7e9cd52e1c94bf170ecdee3bf2ebec81a0a9ed2e954b81d7369f1dd880104306c2067e3b9fccf8d96b4c7ad48a4e45b1e1429bdafef861c465b5c45
6
+ metadata.gz: 832837820eb0ecb86bef7fe3cf84514f52a22b92933fb08bf9fd8d3cb48fee690585ba42c853bee984bdd801fb93c7fdaa96e5a873ffee93b976b417f99f08b0
7
+ data.tar.gz: f7b4a6ba6ba8ad367db6d6d486fc041471f73a7fa72663ed90ee71ed1445122d287c92c2ceaef986b9b7572445f3c03960e4a65f01af4588be904b2c67d251a8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
10
+ ## 0.2.0
11
+
12
+ - Detect native gRPC constructors in the master before fork.
13
+ - Support experimental Linux grpc fork callbacks for parent-side clients.
14
+
3
15
  ## 0.1.0
4
16
 
5
17
  Initial release.
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 server currently supports a single process and 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
 
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Constructor instrumentation installed without activating a master guard.
4
+ # @api private
5
+ module Gritz::Native::ForkGuard
6
+ [GRPC::Core::Channel, GRPC::Core::Server, GRPC::Core::ChannelCredentials,
7
+ GRPC::Core::ServerCredentials, GRPC::Core::CallCredentials, GRPC::ClientStub, GRPC::RpcServer].each do |klass|
8
+ Gritz::ForkGuard.install(klass)
9
+ end
10
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gritz
4
4
  module Native
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/gritz/native.rb CHANGED
@@ -6,8 +6,10 @@ require "google/rpc/status_pb"
6
6
  require "google/protobuf/any_pb"
7
7
  require "google/protobuf/well_known_types"
8
8
  require_relative "native/version"
9
+ require_relative "native/fork_guard"
9
10
  require_relative "transport/native"
10
11
  require_relative "transport/native/call"
11
12
  require_relative "transport/native/bridge"
12
13
  require_relative "transport/native/server"
14
+ require_relative "transport/native/health"
13
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,16 @@ 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
+
13
+ def self.prefork
14
+ GRPC.prefork
15
+ rescue RuntimeError => e
16
+ raise "#{e.message}. fork_mode :grpc_fork_support requires GRPC_ENABLE_FORK_SUPPORT=1 before requiring grpc."
17
+ end
18
+
19
+ def self.postfork_parent = GRPC.postfork_parent
20
+ def self.postfork_child = GRPC.postfork_child
12
21
 
13
22
  def initialize(config:, dispatcher:, logger:)
14
23
  @config = config
@@ -23,9 +32,9 @@ module Gritz
23
32
  # @return [Integer] the port selected by C-core
24
33
  def bind(listener_spec = @config.bind)
25
34
  raise ArgumentError, "transport is already bound" if @server
26
- raise ArgumentError, "TLS is not available in this version" unless @config.tls.empty?
27
35
  raise ArgumentError, "at least one controller must be registered" if @dispatcher.router.routes.empty?
28
36
 
37
+ credentials = server_credentials
29
38
  created_server = @server = Server.new(
30
39
  pool_size: @config.threads,
31
40
  # grpc 1.83 accepts but ignores this value: busy pools reject immediately.
@@ -38,7 +47,9 @@ module Gritz
38
47
  @dispatcher.router.routes.values.group_by(&:service_class).each do |service_class, descriptors|
39
48
  @server.handle(Bridge.build(service_class, descriptors, self))
40
49
  end
41
- @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)
42
53
  raise ArgumentError, "could not bind #{listener_spec}" unless @port.positive?
43
54
 
44
55
  @port
@@ -53,7 +64,10 @@ module Gritz
53
64
  raise ArgumentError, "transport is already started" if @thread
54
65
 
55
66
  @thread = Thread.new { @server.run }
56
- return self if @server.wait_till_running(5)
67
+ if @server.wait_till_running(5)
68
+ refresh_health
69
+ return self
70
+ end
57
71
 
58
72
  @thread.value unless @thread.alive?
59
73
  kill
@@ -67,6 +81,7 @@ module Gritz
67
81
  def running? = @thread&.alive? && @server.running?
68
82
 
69
83
  def stop(deadline:)
84
+ drain!
70
85
  unless @thread
71
86
  @server&.close_unstarted
72
87
  @server = nil
@@ -79,11 +94,35 @@ module Gritz
79
94
 
80
95
  def kill = stop(deadline: Time.now)
81
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
+
82
120
  def stats
121
+ busy = @server&.busy_threads || 0
83
122
  @lock.synchronize do
84
123
  oldest = @inflight.values.min
85
124
  {
86
- inflight: @inflight.size, busy: @inflight.size, capacity: @config.threads,
125
+ inflight: @inflight.size, busy: busy, capacity: @config.threads,
87
126
  rejected_total: @rejected_total, requests_total: @requests_total,
88
127
  oldest_inflight_age: oldest ? Process.clock_gettime(Process::CLOCK_MONOTONIC) - oldest : 0
89
128
  }
@@ -105,6 +144,14 @@ module Gritz
105
144
 
106
145
  private
107
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
+
108
155
  def status_exception(call, error)
109
156
  metadata = call.trailing_metadata.merge(error.metadata)
110
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.1.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.1.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.1.0
45
+ version: 0.3.0
46
46
  - !ruby/object:Gem::Dependency
47
47
  name: grpc
48
48
  requirement: !ruby/object:Gem::Requirement
@@ -75,11 +75,13 @@ files:
75
75
  - LICENSE.txt
76
76
  - README.md
77
77
  - lib/gritz/native.rb
78
+ - lib/gritz/native/fork_guard.rb
78
79
  - lib/gritz/native/version.rb
79
80
  - lib/gritz/testing/server.rb
80
81
  - lib/gritz/transport/native.rb
81
82
  - lib/gritz/transport/native/bridge.rb
82
83
  - lib/gritz/transport/native/call.rb
84
+ - lib/gritz/transport/native/health.rb
83
85
  - lib/gritz/transport/native/server.rb
84
86
  homepage: https://github.com/gritzrpc/gritz-native
85
87
  licenses:
@@ -103,7 +105,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
103
105
  - !ruby/object:Gem::Version
104
106
  version: '0'
105
107
  requirements: []
106
- rubygems_version: 4.0.16
108
+ rubygems_version: 3.6.9
107
109
  specification_version: 4
108
110
  summary: gRPC C-core transport for Gritz
109
111
  test_files: []