tonic-rails 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.
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # Runs the gRPC server: a tonic transport in Rust plus a pool of Ruby
6
+ # dispatch threads.
7
+ #
8
+ # server = Tonic::Rails::Server.new
9
+ # server.register(ProductService)
10
+ # server.start # blocks until SIGTERM / SIGINT or #stop
11
+ #
12
+ # Shutdown is graceful: health checks flip to NOT_SERVING, new
13
+ # connections are refused, in-flight RPCs get `drain_timeout` seconds
14
+ # to finish, then everything is torn down.
15
+ class Server
16
+ attr_reader :router, :configuration
17
+
18
+ def initialize(configuration: Tonic::Rails.configuration)
19
+ @configuration = configuration
20
+ @router = Router.new
21
+ @native = nil
22
+ @state = :stopped
23
+ @stop_reader = nil
24
+ @stop_writer = nil
25
+ @stop_requested = false
26
+ @lock = Mutex.new
27
+ end
28
+
29
+ # @param service_class [Class, String] handler class (or its name)
30
+ def register(service_class)
31
+ service_class = Object.const_get(service_class) if service_class.is_a?(String)
32
+ service_class.register_on(@router)
33
+ self
34
+ end
35
+
36
+ # Start serving and block until stopped.
37
+ # @param install_signal_handlers [Boolean] trap TERM and INT to stop.
38
+ # Pass false when embedding in a process that owns its signals.
39
+ # @yield once the server is accepting connections
40
+ def start(install_signal_handlers: true)
41
+ @lock.synchronize do
42
+ raise Error, "server is already running" unless @state == :stopped
43
+
44
+ @state = :starting
45
+ @stop_requested = false
46
+ end
47
+
48
+ begin
49
+ raise Error, "no services registered" if @router.empty?
50
+
51
+ @configuration.validate!
52
+ Tonic::Rails.native_extension!
53
+ Tonic::Rails._set_shutting_down(false)
54
+ @stop_reader, @stop_writer = IO.pipe
55
+ stop if @stop_requested # requested while we were starting up
56
+ @native = build_native_server
57
+ @native.start_server
58
+ @state = :running
59
+
60
+ previous_handlers = install_signal_handlers ? trap_signals : {}
61
+ workers = Array.new(@configuration.dispatch_threads) { |i| spawn_worker(i) }
62
+ log_startup
63
+ yield self if block_given?
64
+
65
+ wait_for_stop(workers)
66
+ shutdown(workers)
67
+ ensure
68
+ previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
69
+ @native&.stop_server
70
+ @native = nil
71
+ [@stop_reader, @stop_writer].each { |io| io.close if io && !io.closed? }
72
+ @stop_reader = @stop_writer = nil
73
+ Tonic::Rails._set_shutting_down(false)
74
+ @state = :stopped
75
+ end
76
+ end
77
+
78
+ # Request a graceful stop. Safe to call from signal handlers and
79
+ # other threads; returns immediately.
80
+ def stop
81
+ @stop_requested = true
82
+ @stop_writer&.write_nonblock(".", exception: false)
83
+ rescue IOError
84
+ # Already stopped.
85
+ end
86
+
87
+ def running?
88
+ @state == :running
89
+ end
90
+
91
+ # @return [Hash{Symbol=>Integer}] request and connection counters
92
+ def stats
93
+ native = @native
94
+ return native.stats if native
95
+
96
+ { total: 0, successful: 0, errors: 0, cancelled: 0, deadline_exceeded: 0,
97
+ resource_exhausted: 0, unimplemented: 0, active_connections: 0, queued: 0 }
98
+ end
99
+
100
+ private
101
+
102
+ def build_native_server
103
+ config = @configuration
104
+ native = TonicRails::Native::Server.new(config.bind_address, config.max_message_size)
105
+ native.on_request(build_callback)
106
+ native.set_routes(@router.paths)
107
+ if config.tls?
108
+ native.set_tls(config.tls_cert_path, config.tls_key_path)
109
+ native.set_tls_client_ca(config.tls_client_ca_path) if config.tls_client_ca_path
110
+ end
111
+ native.set_max_connections(config.max_connections)
112
+ native.set_concurrency_limit_per_connection(config.concurrency_limit_per_connection)
113
+ native.set_worker_threads(config.worker_threads || 0)
114
+ native.set_keepalive(config.keepalive_interval.to_f, config.keepalive_timeout.to_f)
115
+ native.set_dispatch_queue_size(config.dispatch_queue_size)
116
+ native.set_log_requests(config.log_requests ? true : false)
117
+ native.set_compression(config.compression ? true : false)
118
+ native.set_reflection(config.reflection ? true : false)
119
+ native.set_reflection_descriptors(Reflection.file_descriptors(@router.service_names)) if config.reflection
120
+ native
121
+ end
122
+
123
+ # The proc the transport calls for every RPC:
124
+ # (path, metadata, call) → encoded response String, or nil if streamed
125
+ def build_callback
126
+ router = @router
127
+ call = ->(path, metadata, transport) { router.dispatch(path, metadata, transport) }
128
+
129
+ call = @configuration.interceptors.reverse.reduce(call) do |inner, interceptor|
130
+ lambda do |path, metadata, transport|
131
+ interceptor.call(path, metadata, -> { inner.call(path, metadata, transport) })
132
+ end
133
+ end
134
+
135
+ guarded = lambda do |path, metadata, transport|
136
+ call.call(path, metadata, transport)
137
+ rescue RpcError
138
+ raise
139
+ rescue StandardError => e
140
+ Tonic::Rails.log_exception(e, path)
141
+ raise RpcError.new("internal error", code: :internal)
142
+ end
143
+
144
+ wrap_with_rails_executor(guarded)
145
+ end
146
+
147
+ # Each RPC runs inside the Rails executor (or reloader, when code
148
+ # reloading is on): ActiveRecord connections are returned, query
149
+ # caches and CurrentAttributes are reset — the same isolation Rack
150
+ # requests and Active Job get.
151
+ def wrap_with_rails_executor(callback)
152
+ return callback unless defined?(::Rails.application) && ::Rails.application
153
+
154
+ app = ::Rails.application
155
+ wrapper = @configuration.reload_handlers ? app.reloader : app.executor
156
+ ->(path, metadata, transport) { wrapper.wrap { callback.call(path, metadata, transport) } }
157
+ end
158
+
159
+ def spawn_worker(index)
160
+ native = @native
161
+ Thread.new do
162
+ Thread.current.name = "tonic-rails-dispatch-#{index}"
163
+ native.serve_worker
164
+ rescue Exception => e # rubocop:disable Lint/RescueException
165
+ Tonic::Rails.log_exception(e, "dispatch thread #{index}")
166
+ stop
167
+ raise
168
+ end
169
+ end
170
+
171
+ def trap_signals
172
+ %w[TERM INT].to_h { |signal| [signal, Signal.trap(signal) { stop }] }
173
+ end
174
+
175
+ def wait_for_stop(workers)
176
+ loop do
177
+ break if @stop_reader.wait_readable(1)
178
+
179
+ dead = workers.reject(&:alive?)
180
+ unless dead.empty?
181
+ @configuration.logger&.error("tonic-rails: #{dead.size} dispatch thread(s) died; shutting down")
182
+ break
183
+ end
184
+ end
185
+ end
186
+
187
+ def shutdown(workers)
188
+ logger = @configuration.logger
189
+ @state = :stopping
190
+ Tonic::Rails._set_shutting_down(true)
191
+ logger&.info("tonic-rails: shutting down, draining for up to #{@configuration.drain_timeout}s")
192
+
193
+ @native.begin_shutdown
194
+ unless @native.wait_for_drain(@configuration.drain_timeout.to_f)
195
+ logger&.warn("tonic-rails: drain timeout expired; closing remaining connections")
196
+ end
197
+ @native.stop_workers
198
+ workers.each do |worker|
199
+ worker.join
200
+ rescue Exception # rubocop:disable Lint/RescueException
201
+ # Already logged by the worker.
202
+ end
203
+ logger&.info("tonic-rails: stopped")
204
+ end
205
+
206
+ def log_startup
207
+ config = @configuration
208
+ logger = config.logger
209
+ return unless logger
210
+
211
+ if config.tls?
212
+ logger.info("tonic-rails: TLS enabled#{' (mutual TLS)' if config.tls_client_ca_path}")
213
+ else
214
+ logger.warn("tonic-rails: TLS is not configured; gRPC traffic is plaintext")
215
+ end
216
+ logger.info("tonic-rails: reflection enabled") if config.reflection
217
+ logger.info(
218
+ "tonic-rails: serving #{@router.service_names.join(', ')} on #{config.bind_address} " \
219
+ "(#{config.dispatch_threads} dispatch threads)",
220
+ )
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ namespace :tonic_rails do
6
+ desc "Start the gRPC server (boots the Rails app with its registered services)"
7
+ task serve: :environment do
8
+ # Eager-load so handlers registered via config.to_prepare and
9
+ # autoloaded constants are in place, as they would be in production.
10
+ Rails.application.eager_load! unless Rails.application.config.eager_load
11
+ Tonic::Rails.server.start
12
+ end
13
+
14
+ desc "List registered gRPC methods"
15
+ task routes: :environment do
16
+ router = Tonic::Rails.server.router
17
+ if router.empty?
18
+ puts "No gRPC services registered."
19
+ else
20
+ router.paths.sort.each { |path| puts path }
21
+ end
22
+ end
23
+
24
+ desc "Generate Ruby protobuf classes (PROTO_DIR=app/protos RUBY_OUT=lib/grpc; requires protoc)"
25
+ task :compile_protos do
26
+ proto_dir = ENV.fetch("PROTO_DIR", "app/protos")
27
+ ruby_out = ENV.fetch("RUBY_OUT", "lib/grpc")
28
+ protos = Dir.glob(File.join(proto_dir, "**", "*.proto"))
29
+ abort "No .proto files found in #{proto_dir}/" if protos.empty?
30
+ abort "protoc not found on PATH. Install the Protocol Buffers compiler." unless system("protoc", "--version", out: File::NULL)
31
+
32
+ FileUtils.mkdir_p(ruby_out)
33
+ sh "protoc", "--ruby_out=#{ruby_out}", "-I#{proto_dir}", *protos
34
+ puts "Compiled #{protos.size} proto file(s) to #{ruby_out}/"
35
+ end
36
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # Call handlers in-process, without a network or the native extension.
6
+ # Requests and responses go through real protobuf encoding, so tests
7
+ # exercise the same conversion the server does.
8
+ #
9
+ # require "tonic/rails/testing"
10
+ #
11
+ # response = Tonic::Rails::Testing.call(ProductService, :GetProduct, { id: 1 })
12
+ # response.title # => "Widget"
13
+ #
14
+ # expect { Tonic::Rails::Testing.call(ProductService, :GetProduct, { id: 0 }) }
15
+ # .to raise_error(Tonic::Rails::RpcError) { |e| expect(e.code_name).to eq(:not_found) }
16
+ module Testing
17
+ # Request side of a call: yields pre-encoded messages.
18
+ class Reader
19
+ def initialize(messages, error: nil)
20
+ @messages = messages.dup
21
+ @error = error
22
+ end
23
+
24
+ def next
25
+ @messages.shift
26
+ end
27
+
28
+ attr_reader :error
29
+ end
30
+
31
+ # Response side of a call: collects encoded messages.
32
+ class Sender
33
+ attr_reader :messages
34
+
35
+ def initialize
36
+ @messages = []
37
+ end
38
+
39
+ def push(bytes)
40
+ @messages << bytes
41
+ end
42
+ end
43
+
44
+ module_function
45
+
46
+ # @param service_class [Class] handler class
47
+ # @param rpc_name [Symbol, String] RPC name as in the .proto
48
+ # @param request [Google::Protobuf::AbstractMessage, Hash, Array] the
49
+ # request; an Array of them for client- and bidi-streaming RPCs
50
+ # @param metadata [Hash{String=>String}]
51
+ # @param timeout_ms [Integer, nil] simulated client deadline
52
+ # @return [Google::Protobuf::AbstractMessage, Array] the response, or
53
+ # an Array of responses for server- and bidi-streaming RPCs
54
+ # @raise [RpcError] when the handler ends the call with a status
55
+ def call(service_class, rpc_name, request, metadata: {}, timeout_ms: nil)
56
+ spec = service_class.grpc_rpcs.fetch(rpc_name.to_s) do
57
+ raise ArgumentError, "#{service_class} has no RPC #{rpc_name}"
58
+ end
59
+
60
+ requests = spec[:request_stream] ? Array(request) : [request]
61
+ reader = Reader.new(requests.map { |r| encode(spec[:request], r) })
62
+ sender = Sender.new
63
+
64
+ result = service_class.new.call_rpc(
65
+ rpc_name.to_s,
66
+ metadata: metadata.transform_keys(&:to_s),
67
+ reader: reader,
68
+ sender: sender,
69
+ timeout_ms: timeout_ms,
70
+ )
71
+
72
+ if spec[:response_stream]
73
+ sender.messages.map { |bytes| spec[:response].decode(bytes) }
74
+ else
75
+ spec[:response].decode(result)
76
+ end
77
+ end
78
+
79
+ def encode(klass, message)
80
+ message = klass.new(message) if message.is_a?(Hash)
81
+ klass.encode(message)
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "google/protobuf"
4
+
5
+ module Tonic
6
+ module Rails
7
+ class Error < StandardError; end
8
+ class NotConfiguredError < Error; end
9
+ end
10
+ end
11
+
12
+ require "tonic/rails/version"
13
+ require "tonic/rails/rpc_error"
14
+ require "tonic/rails/configuration"
15
+ require "tonic/rails/router"
16
+ require "tonic/rails/base"
17
+ require "tonic/rails/reflection"
18
+ require "tonic/rails/server"
19
+
20
+ module Tonic
21
+ module Rails
22
+ @configuration = Configuration.new
23
+ @shutting_down = false
24
+ @native_load_error = nil
25
+
26
+ class << self
27
+ attr_reader :configuration
28
+
29
+ def configure
30
+ yield @configuration
31
+ end
32
+
33
+ # The process-wide server used by the Railtie and rake tasks.
34
+ def server
35
+ @server ||= Server.new
36
+ end
37
+
38
+ # True once shutdown has begun. Long-running handlers (especially
39
+ # streams) should check this and finish early.
40
+ def cancelled?
41
+ @shutting_down
42
+ end
43
+
44
+ # @api private
45
+ def _set_shutting_down(value)
46
+ @shutting_down = value
47
+ end
48
+
49
+ # @api private
50
+ # Log an unexpected exception (and hand it to the Rails error
51
+ # reporter, if present) without exposing it to the client.
52
+ def log_exception(error, context)
53
+ configuration.logger&.error(
54
+ "tonic-rails: #{context}: #{error.class}: #{error.message}\n" \
55
+ "#{Array(error.backtrace).first(20).join("\n")}",
56
+ )
57
+ if defined?(::Rails.error) && ::Rails.respond_to?(:error)
58
+ ::Rails.error.report(error, handled: true, source: "tonic-rails", context: { rpc: context })
59
+ end
60
+ rescue StandardError
61
+ nil
62
+ end
63
+
64
+ # @api private
65
+ def native_extension!
66
+ return if defined?(::TonicRails::Native::Server)
67
+
68
+ raise Error, "tonic-rails native extension is not available " \
69
+ "(#{@native_load_error&.message || 'unknown error'}). " \
70
+ "Reinstall the gem, or run `bundle exec rake compile` in a checkout."
71
+ end
72
+
73
+ # @api private
74
+ def load_native_extension
75
+ # Precompiled platform gems ship one binary per Ruby minor version.
76
+ ruby_version = RUBY_VERSION[/\d+\.\d+/]
77
+ require "tonic/rails/#{ruby_version}/tonic_rails_native"
78
+ rescue LoadError
79
+ begin
80
+ require "tonic/rails/tonic_rails_native"
81
+ rescue LoadError => e
82
+ @native_load_error = e
83
+ end
84
+ end
85
+ end
86
+
87
+ load_native_extension
88
+ end
89
+ end
90
+
91
+ require "tonic/rails/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tonic-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matt Tanous
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: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: google-protobuf
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '4.27'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '4.27'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rb_sys
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.9.110
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 0.9.110
54
+ description: |
55
+ Tonic::Rails serves gRPC from Rails applications. The transport layer
56
+ (HTTP/2 framing, connection management, TLS, health checks, reflection)
57
+ is handled by tonic in Rust and exposed to Ruby through a native
58
+ extension built with Magnus. Protobuf (de)serialization and business
59
+ logic remain in Ruby, giving you Rails-grade handlers, routing,
60
+ interceptors, and the Rails executor around every RPC.
61
+ email:
62
+ - matt.tanous@hey.com
63
+ executables: []
64
+ extensions:
65
+ - ext/tonic_rails_native/extconf.rb
66
+ extra_rdoc_files: []
67
+ files:
68
+ - CHANGELOG.md
69
+ - Cargo.lock
70
+ - Cargo.toml
71
+ - LICENSE
72
+ - README.md
73
+ - ext/tonic_rails_native/Cargo.toml
74
+ - ext/tonic_rails_native/extconf.rb
75
+ - ext/tonic_rails_native/src/body.rs
76
+ - ext/tonic_rails_native/src/dispatch.rs
77
+ - ext/tonic_rails_native/src/framing.rs
78
+ - ext/tonic_rails_native/src/gvl.rs
79
+ - ext/tonic_rails_native/src/lib.rs
80
+ - ext/tonic_rails_native/src/net.rs
81
+ - ext/tonic_rails_native/src/server.rs
82
+ - lib/tonic-rails.rb
83
+ - lib/tonic/rails/base.rb
84
+ - lib/tonic/rails/configuration.rb
85
+ - lib/tonic/rails/railtie.rb
86
+ - lib/tonic/rails/reflection.rb
87
+ - lib/tonic/rails/router.rb
88
+ - lib/tonic/rails/rpc_error.rb
89
+ - lib/tonic/rails/server.rb
90
+ - lib/tonic/rails/tasks/tonic-rails.rake
91
+ - lib/tonic/rails/testing.rb
92
+ - lib/tonic/rails/version.rb
93
+ homepage: https://github.com/CodingAnarchy/tonic-rails
94
+ licenses:
95
+ - MIT
96
+ metadata:
97
+ allowed_push_host: https://rubygems.org
98
+ homepage_uri: https://github.com/CodingAnarchy/tonic-rails
99
+ source_code_uri: https://github.com/CodingAnarchy/tonic-rails
100
+ bug_tracker_uri: https://github.com/CodingAnarchy/tonic-rails/issues
101
+ changelog_uri: https://github.com/CodingAnarchy/tonic-rails/blob/main/CHANGELOG.md
102
+ rubygems_mfa_required: 'true'
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '3.3'
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: 3.3.11
116
+ requirements: []
117
+ rubygems_version: 3.6.9
118
+ specification_version: 4
119
+ summary: High-performance gRPC endpoints for Rails, powered by tonic via a Rust native
120
+ extension
121
+ test_files: []