gritz-native 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1acc3257753cf7f6cb65db29e2c77692136a2050044b225b2cc73880fe67e9d3
4
+ data.tar.gz: 665dcaae2cc563da543b1305337cce4634d6094bfd39863ed09762765bae35c3
5
+ SHA512:
6
+ metadata.gz: 0ee1d520c991ea1b015215651b60a94071e73ccdf130b3e1bbf2982b4b72be90c448d3a2d89db82b37132f170bd6428455428f54d20be68c11f57a8b329be368
7
+ data.tar.gz: b1f58698b7e9cd52e1c94bf170ecdee3bf2ebec81a0a9ed2e954b81d7369f1dd880104306c2067e3b9fccf8d96b4c7ad48a4e45b1e1429bdafef861c465b5c45
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,37 @@
1
+ # Gritz Native
2
+
3
+ The native gRPC transport adapter for Gritz, using the official `grpc` gem (C-core) and a thread pool. It supports unary, server streaming, client streaming and bidirectional streaming, and provides the real-socket testing helper.
4
+
5
+ Requires CRuby 3.3 or later and grpc 1.83 or later. Linux and macOS are tested.
6
+
7
+ ```ruby
8
+ require "gritz/native"
9
+ ```
10
+
11
+ The entry point loads `gritz-core` and registers `Gritz::Transport::Native`. The configuration value is `transport :native`. Use [gritz](https://github.com/gritzrpc/gritz) for the default combination and executable. The Fiber adapter `gritz-async` is planned separately.
12
+
13
+ ```ruby
14
+ Gritz::Testing::Server.start(controllers: [GreeterController]) do |server|
15
+ stub = Helloworld::Greeter::Stub.new(server.address, :this_channel_is_insecure)
16
+ stub.say_hello(Helloworld::HelloRequest.new(name: "Ruby"))
17
+ end
18
+ ```
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.
21
+
22
+ ## Development
23
+
24
+ ```sh
25
+ git clone https://github.com/gritzrpc/gritz-native.git
26
+ cd gritz-native
27
+ bundle install
28
+ COVERAGE=1 bundle exec rake
29
+ bundle exec rubocop
30
+ bundle exec rake build
31
+ ```
32
+
33
+ Integration tests contain their own protobuf fixtures and exercise all four RPC forms over real sockets. The [Linux devcontainer](.devcontainer/devcontainer.json) includes grpcurl and ghz. For local core changes, use the Bundler override in [CONTRIBUTING.md](CONTRIBUTING.md). See [SECURITY.md](SECURITY.md) and the [release guide](docs/guides/releasing.md).
34
+
35
+ ## License
36
+
37
+ [MIT](LICENSE.txt).
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ module Native
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gritz/core"
4
+ require "grpc"
5
+ require "google/rpc/status_pb"
6
+ require "google/protobuf/any_pb"
7
+ require "google/protobuf/well_known_types"
8
+ require_relative "native/version"
9
+ require_relative "transport/native"
10
+ require_relative "transport/native/call"
11
+ require_relative "transport/native/bridge"
12
+ require_relative "transport/native/server"
13
+ require_relative "testing/server"
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Gritz
6
+ module Testing
7
+ # Starts a single-process server for integration tests.
8
+ # @api public
9
+ class Server
10
+ attr_reader :port, :address, :transport
11
+
12
+ def self.start(config = nil, controllers: nil, middleware: nil, logger: Logger.new(File::NULL))
13
+ config ||= Configuration.new.tap do |settings|
14
+ settings.workers = 0
15
+ settings.bind = "127.0.0.1:0"
16
+ end
17
+ config.controllers = controllers if controllers
18
+ config.middleware = middleware if middleware
19
+ server = new(config, logger:).start
20
+ return server unless block_given?
21
+
22
+ begin
23
+ yield server
24
+ ensure
25
+ server.stop
26
+ end
27
+ end
28
+
29
+ def initialize(config, logger:)
30
+ @config = config
31
+ @logger = logger
32
+ config.validate_single_process!
33
+ end
34
+
35
+ def start
36
+ raise ArgumentError, "Testing::Server is already started or stopped" if @started || @stopped
37
+
38
+ @started = true
39
+ begin
40
+ @config.preload! if @config.preload_app?
41
+ router = Router.new(controllers: @config.controllers, strict: @config.strict_routes, logger: @logger)
42
+ dispatcher = Dispatcher.new(router:, middleware: @config.middleware, logger: @logger)
43
+ @transport = Transport::Native.new(config: @config, dispatcher:, logger: @logger)
44
+ @lifecycle_started = true
45
+ @config.run_hooks(:on_worker_boot, 0)
46
+ @port = transport.bind
47
+ @address = @config.bind.sub(/:\d+\z/, ":#{port}")
48
+ transport.start
49
+ self
50
+ rescue StandardError
51
+ stop
52
+ raise
53
+ end
54
+ end
55
+
56
+ def stop
57
+ return if @stopped
58
+
59
+ @stopped = true
60
+ begin
61
+ transport&.stop(deadline: Time.now + @config.shutdown_timeout)
62
+ ensure
63
+ @config.run_hooks(:on_worker_shutdown, 0) if @lifecycle_started
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ module Transport
5
+ class Native
6
+ # Installs handlers with the arities checked by GRPC::RpcDesc.
7
+ # @api private
8
+ module Bridge
9
+ def self.build(service_class, descriptors, adapter)
10
+ Class.new(service_class) do
11
+ self.service_name = service_class.service_name
12
+ descriptors.each do |descriptor|
13
+ case descriptor.kind
14
+ when :client_streaming
15
+ define_method(descriptor.action) do |view|
16
+ adapter.dispatch(Call.new(method_descriptor: descriptor, view:, messages: view.each_remote_read))
17
+ end
18
+ when :unary
19
+ define_method(descriptor.action) do |request, view|
20
+ adapter.dispatch(Call.new(method_descriptor: descriptor, view:, messages: [request]))
21
+ end
22
+ else
23
+ define_method(descriptor.action) do |request, view|
24
+ messages = descriptor.client_streaming? ? request : [request]
25
+ Enumerator.new do |writer|
26
+ adapter.dispatch(Call.new(method_descriptor: descriptor, view:, messages:, writer:))
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ module Transport
5
+ class Native
6
+ # Translates the public grpc views into the Call contract.
7
+ # @api private
8
+ class Call
9
+ include Gritz::Call
10
+
11
+ attr_reader :method_descriptor
12
+
13
+ def initialize(method_descriptor:, view:, messages:, writer: nil)
14
+ @method_descriptor = method_descriptor
15
+ @view = view
16
+ @messages = messages.to_enum
17
+ @writer = writer
18
+ end
19
+
20
+ # SingleReqView and MultiReqView both expose these methods in grpc 1.83.
21
+ # Client streams add each_remote_read; bidi passes a separate request enum.
22
+ def metadata = @view.metadata
23
+ # C-core represents an unset deadline as a Time at epoch - 1, not nil.
24
+ def deadline = @view.deadline.to_r == -1 ? nil : @view.deadline
25
+ def peer = @view.peer
26
+ def peer_identity = @view.peer_cert
27
+ def cancelled? = @view.cancelled?
28
+ def trailing_metadata = @view.output_metadata
29
+ def send_initial_metadata(metadata = {}) = @view.send_initial_metadata(metadata)
30
+ def merge_initial_metadata(metadata = {}) = @view.merge_metadata_to_send(metadata)
31
+
32
+ def read
33
+ @messages.next
34
+ rescue StopIteration
35
+ nil
36
+ end
37
+
38
+ def each_message
39
+ return enum_for(:each_message) unless block_given?
40
+
41
+ loop do
42
+ message = read
43
+ break unless message
44
+
45
+ yield message
46
+ end
47
+ end
48
+
49
+ def write(message)
50
+ raise Gritz::Errors::Internal, "RPC does not have a response stream" unless @writer
51
+
52
+ @view.send_initial_metadata
53
+ @writer << message
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gritz
4
+ module Transport
5
+ class Native
6
+ # Adapts RpcServer's lifecycle and rejection callbacks.
7
+ # @api private
8
+ class Server < GRPC::RpcServer
9
+ def initialize(on_rejected:, **)
10
+ @on_rejected = on_rejected
11
+ super(**)
12
+ end
13
+
14
+ def available?(rpc)
15
+ result = super
16
+ @on_rejected.call unless result
17
+ result
18
+ end
19
+
20
+ def stop(deadline:)
21
+ # grpc 1.83's stop takes no deadline and reads @poll_period instead.
22
+ # Set its relative grace from the caller's absolute shutdown deadline.
23
+ @poll_period = [deadline - Time.now, 0].max
24
+ super()
25
+ end
26
+
27
+ def close_unstarted
28
+ return unless running_state == :not_started
29
+
30
+ @pool.stop
31
+ # C-core requires shutdown even when a bound listener was never started.
32
+ @server.shutdown_and_notify(Time.now)
33
+ @server.close
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set" # rubocop:disable Lint/RedundantRequireStatement -- required by Ruby 3.3
4
+ require "securerandom"
5
+
6
+ module Gritz
7
+ module Transport
8
+ # Runs generated services through Gritz's transport-independent dispatcher.
9
+ # @api public
10
+ class Native
11
+ def self.capabilities = Set[:unary, :client_streaming, :server_streaming, :bidi, :reuseport].freeze
12
+
13
+ def initialize(config:, dispatcher:, logger:)
14
+ @config = config
15
+ @dispatcher = dispatcher
16
+ @logger = logger
17
+ @lock = Mutex.new
18
+ @inflight = {}.compare_by_identity
19
+ @requests_total = @rejected_total = 0
20
+ end
21
+
22
+ # Creates C-core resources only when binding, so construction is safe before fork.
23
+ # @return [Integer] the port selected by C-core
24
+ def bind(listener_spec = @config.bind)
25
+ raise ArgumentError, "transport is already bound" if @server
26
+ raise ArgumentError, "TLS is not available in this version" unless @config.tls.empty?
27
+ raise ArgumentError, "at least one controller must be registered" if @dispatcher.router.routes.empty?
28
+
29
+ created_server = @server = Server.new(
30
+ pool_size: @config.threads,
31
+ # grpc 1.83 accepts but ignores this value: busy pools reject immediately.
32
+ max_waiting_requests: @config.max_waiting_requests,
33
+ poll_period: @config.shutdown_timeout,
34
+ pool_keep_alive: 0,
35
+ server_args: @config.server_args,
36
+ on_rejected: -> { @lock.synchronize { @rejected_total += 1 } }
37
+ )
38
+ @dispatcher.router.routes.values.group_by(&:service_class).each do |service_class, descriptors|
39
+ @server.handle(Bridge.build(service_class, descriptors, self))
40
+ end
41
+ @port = @server.add_http2_port(listener_spec, :this_port_is_insecure)
42
+ raise ArgumentError, "could not bind #{listener_spec}" unless @port.positive?
43
+
44
+ @port
45
+ rescue StandardError
46
+ created_server&.close_unstarted
47
+ @server = nil if created_server
48
+ raise
49
+ end
50
+
51
+ def start
52
+ raise ArgumentError, "bind must be called before start" unless @server
53
+ raise ArgumentError, "transport is already started" if @thread
54
+
55
+ @thread = Thread.new { @server.run }
56
+ return self if @server.wait_till_running(5)
57
+
58
+ @thread.value unless @thread.alive?
59
+ kill
60
+ raise "gRPC server did not start within 5 seconds"
61
+ rescue StandardError
62
+ @server&.close_unstarted
63
+ raise
64
+ end
65
+
66
+ def wait = @thread&.value
67
+ def running? = @thread&.alive? && @server.running?
68
+
69
+ def stop(deadline:)
70
+ unless @thread
71
+ @server&.close_unstarted
72
+ @server = nil
73
+ return
74
+ end
75
+
76
+ @server.stop(deadline:)
77
+ wait
78
+ end
79
+
80
+ def kill = stop(deadline: Time.now)
81
+
82
+ def stats
83
+ @lock.synchronize do
84
+ oldest = @inflight.values.min
85
+ {
86
+ inflight: @inflight.size, busy: @inflight.size, capacity: @config.threads,
87
+ rejected_total: @rejected_total, requests_total: @requests_total,
88
+ oldest_inflight_age: oldest ? Process.clock_gettime(Process::CLOCK_MONOTONIC) - oldest : 0
89
+ }
90
+ end
91
+ end
92
+
93
+ # @api private
94
+ def dispatch(call)
95
+ @lock.synchronize do
96
+ @inflight[call] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
97
+ @requests_total += 1
98
+ end
99
+ @dispatcher.call(call)
100
+ rescue Gritz::Error => e
101
+ raise status_exception(call, e)
102
+ ensure
103
+ @lock.synchronize { @inflight.delete(call) }
104
+ end
105
+
106
+ private
107
+
108
+ def status_exception(call, error)
109
+ metadata = call.trailing_metadata.merge(error.metadata)
110
+ unless error.details.empty?
111
+ details = error.details.map do |detail|
112
+ detail.is_a?(Google::Protobuf::Any) ? detail : Google::Protobuf::Any.pack(detail)
113
+ end
114
+ status = Google::Rpc::Status.new(code: error.grpc_code, message: error.message, details:)
115
+ metadata["grpc-status-details-bin"] = Google::Rpc::Status.encode(status)
116
+ end
117
+ GRPC::BadStatus.new_status_exception(error.grpc_code, error.message, metadata)
118
+ rescue StandardError => e
119
+ error_id = SecureRandom.uuid
120
+ @logger.error("#{error_id}: #{e.full_message}")
121
+ GRPC::Internal.new("internal error (#{error_id})", { "error-id" => error_id })
122
+ end
123
+ end
124
+ end
125
+ end
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gritz-native
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: googleapis-common-protos-types
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.20'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '2'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '1.20'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '2'
32
+ - !ruby/object:Gem::Dependency
33
+ name: gritz-core
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - '='
37
+ - !ruby/object:Gem::Version
38
+ version: 0.1.0
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - '='
44
+ - !ruby/object:Gem::Version
45
+ version: 0.1.0
46
+ - !ruby/object:Gem::Dependency
47
+ name: grpc
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '1.83'
53
+ - - "<"
54
+ - !ruby/object:Gem::Version
55
+ version: '2'
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '1.83'
63
+ - - "<"
64
+ - !ruby/object:Gem::Version
65
+ version: '2'
66
+ description: The native gRPC adapter for Gritz, using the official grpc gem and a
67
+ thread pool.
68
+ email:
69
+ - t.yudai92@gmail.com
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - CHANGELOG.md
75
+ - LICENSE.txt
76
+ - README.md
77
+ - lib/gritz/native.rb
78
+ - lib/gritz/native/version.rb
79
+ - lib/gritz/testing/server.rb
80
+ - lib/gritz/transport/native.rb
81
+ - lib/gritz/transport/native/bridge.rb
82
+ - lib/gritz/transport/native/call.rb
83
+ - lib/gritz/transport/native/server.rb
84
+ homepage: https://github.com/gritzrpc/gritz-native
85
+ licenses:
86
+ - MIT
87
+ metadata:
88
+ allowed_push_host: https://rubygems.org
89
+ source_code_uri: https://github.com/gritzrpc/gritz-native
90
+ changelog_uri: https://github.com/gritzrpc/gritz-native/blob/main/CHANGELOG.md
91
+ rubygems_mfa_required: 'true'
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '3.3'
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubygems_version: 4.0.16
107
+ specification_version: 4
108
+ summary: gRPC C-core transport for Gritz
109
+ test_files: []