gritz-async 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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +87 -0
- data/exe/gritz +10 -0
- data/lib/gritz/async/service.rb +33 -0
- data/lib/gritz/async/version.rb +7 -0
- data/lib/gritz/async.rb +7 -0
- data/lib/gritz/transport/async/body.rb +29 -0
- data/lib/gritz/transport/async/bridge.rb +85 -0
- data/lib/gritz/transport/async/call.rb +81 -0
- data/lib/gritz/transport/async/input.rb +53 -0
- data/lib/gritz/transport/async/server.rb +85 -0
- data/lib/gritz/transport/async.rb +153 -0
- metadata +149 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 59d377f48d334487af680d8fe06e5df46db7d537575b78e24d0a23bd7c2a6257
|
|
4
|
+
data.tar.gz: 8f6442cfe1bf49317590b37476d52e15cc3212f5a9d971d7bab465a91a428ccb
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7aa534c767bbea2737f46780d40e76d83da144a00352a8ec02896b0961334be10d97e9dd24ab0e375778ee5b8231c04fc1216d6e04a7602e5d4241ae234a2ddf
|
|
7
|
+
data.tar.gz: 1b1999373166196387c5bf5437f9c0cafb22fd7de9270bcd568f09174a47db6b91393720a8e0f81ff84a29e0a4c730b897e289d3c8f7c46cf3232de8004f619d
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ydah
|
|
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,87 @@
|
|
|
1
|
+
# Gritz Async
|
|
2
|
+
|
|
3
|
+
Experimental Fiber-based gRPC server adapter for [Gritz](https://github.com/gritzrpc/gritz-core), built on `async-grpc`. Requires CRuby 3.3+. Each RPC runs in a Fiber; workers inherit a TCP listener that stays open across master replacement.
|
|
4
|
+
|
|
5
|
+
The runtime depends on `gritz-core`, `async-grpc` and their protobuf/HTTP dependencies. It does not install the official `grpc` gem or `gritz-native`. The `gritz` meta gem selects Native, so Async applications install `gritz-async` instead.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "gritz-async", "~> 0.1.0"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Until the initial release, use `gem "gritz-async", git: "https://github.com/gritzrpc/gritz-async.git", branch: "main"`. Core 0.6.0 must be available. See [release instructions](docs/guides/releasing.md).
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
Generate protobuf **messages** with `protoc --ruby_out=lib hello.proto`. Declare the service with the matching protobuf package and service name; the generated `*_services_pb.rb` file uses the official grpc gem and is unnecessary for the Async server.
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# config/gritz.rb
|
|
21
|
+
require "gritz/async"
|
|
22
|
+
require_relative "../lib/hello_pb"
|
|
23
|
+
|
|
24
|
+
class Greeter < Gritz::Async::Service
|
|
25
|
+
self.service_name = "helloworld.Greeter"
|
|
26
|
+
rpc :SayHello, Helloworld::HelloRequest, Helloworld::HelloReply
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class GreeterController < Gritz::Controller
|
|
30
|
+
bind Greeter
|
|
31
|
+
def say_hello
|
|
32
|
+
Helloworld::HelloReply.new(message: "Hello, #{request.message.name}")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
transport :async
|
|
37
|
+
listener_strategy :inherited_fd
|
|
38
|
+
workers 0
|
|
39
|
+
bind "127.0.0.1:50051"
|
|
40
|
+
register_controller GreeterController
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
bundle exec gritz routes -C config/gritz.rb
|
|
45
|
+
bundle exec gritz check -C config/gritz.rb
|
|
46
|
+
bundle exec gritz start -C config/gritz.rb
|
|
47
|
+
grpcurl -plaintext -proto hello.proto -d '{"name":"Ruby"}' \
|
|
48
|
+
127.0.0.1:50051 helloworld.Greeter/SayHello
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The [hello example](examples/hello) implements unary, server-streaming, client-streaming and bidirectional RPCs. The service DSL uses `stream(MessageClass)` for streaming inputs and outputs. Controllers, middleware, errors, request IDs, rich error details and tracing use the same core API as Native.
|
|
52
|
+
|
|
53
|
+
## Concurrency and process lifecycle
|
|
54
|
+
|
|
55
|
+
Set `workers` above zero on Linux to fork multiple workers. With `inherited_fd`, the launcher owns one listening socket and passes it to fresh masters, which fork workers. A port of zero stays stable through worker replacement and `USR2`. `reuseport` is also supported, with the core's usual fixed-port requirement for multiple workers.
|
|
56
|
+
|
|
57
|
+
The adapter has one reactor thread per worker. `threads + max_waiting_requests` bounds concurrent RPC fibers; this is a capacity limit, not a pool of OS threads. Excess calls receive `RESOURCE_EXHAUSTED`. Use scheduler-aware I/O in handlers: blocking native drivers or CPU work stop progress for other RPCs on that worker.
|
|
58
|
+
|
|
59
|
+
Client cancellation and deadlines unwind scheduler-aware handlers. Graceful stop sends HTTP/2 GOAWAY, finishes accepted RPCs until `shutdown_timeout`, then closes outstanding connections. Admin `/livez`, `/readyz`, `/metrics` and `/status`, lifecycle hooks, worker resizing, phased restart and hot reexec remain available through the core supervisor.
|
|
60
|
+
|
|
61
|
+
Incoming frames, decompressed payloads, serialized replies and request metadata respect the configured size limits. Defaults are 4 MiB per message and 8 KiB of metadata.
|
|
62
|
+
|
|
63
|
+
## Initial release limits
|
|
64
|
+
|
|
65
|
+
This experimental release serves plaintext HTTP/2 gRPC. TLS/mTLS, gRPC Health and Reflection are not implemented; TLS and Reflection configuration fails before binding. Use a TLS-terminating gRPC proxy and Gritz's HTTP readiness endpoint when needed.
|
|
66
|
+
|
|
67
|
+
Native connection-age and keepalive settings do not apply to this adapter. Their shared defaults remain unchanged; changing them raises a configuration error. `fork_mode :grpc_fork_support` and Native client factories require `gritz-native`. Async applications can use `Async::GRPC::Client` for outbound calls.
|
|
68
|
+
|
|
69
|
+
For Rails, select the Async transport before `rails_app`; gritz-rails 0.2.0 sets `ActiveSupport::IsolatedExecutionState.isolation_level = :fiber`. Use a scheduler-aware database driver and a suitable connection pool. gritz-otel 0.3.0 supports server tracing in isolated RPC fibers.
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
bundle install
|
|
75
|
+
COVERAGE=1 bundle exec rake
|
|
76
|
+
bundle exec rubocop
|
|
77
|
+
bundle exec bundler-audit check --update
|
|
78
|
+
bundle exec rake build
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
CI runs Ruby 3.3, 3.4 and 4.0. The shared `Gritz::Testing::TransportContract` uses the official grpc client solely as a development dependency, verifying all four RPC forms, metadata, errors, deadlines, cancellation and graceful shutdown. Tests also run a C-core-free cluster through worker kill, resizing, phased replacement and fresh master replacement. See the [validation report](docs/reports/async-validation.md) and [upstream integration decisions](docs/adr/async-transport.md).
|
|
82
|
+
|
|
83
|
+
`pkg/gritz-async.gem` is the strict-built release package. Initial publication and Trusted Publishing setup are performed by the project owner. See [CONTRIBUTING.md](CONTRIBUTING.md) and the [release guide](docs/guides/releasing.md).
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
[MIT](LICENSE.txt).
|
data/exe/gritz
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "gritz/async"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
require "socket"
|
|
7
|
+
|
|
8
|
+
owner_fd = ENV.delete("GRITZ_INTERNAL_OWNER_FD")
|
|
9
|
+
exit Gritz::CLI.new(launch: owner_fd.nil?, owner_io: owner_fd && UNIXSocket.for_fd(Integer(owner_fd)),
|
|
10
|
+
launch_command: [RbConfig.ruby, File.expand_path($PROGRAM_NAME), *ARGV]).run(ARGV)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/grpc/interface"
|
|
4
|
+
|
|
5
|
+
module Gritz
|
|
6
|
+
module Async
|
|
7
|
+
# Protobuf service declarations that need no grpc C extension.
|
|
8
|
+
# Uses protocol-grpc's rpc/stream DSL, with the same service name as the .proto file.
|
|
9
|
+
# @api public
|
|
10
|
+
class Service < Protocol::GRPC::Interface
|
|
11
|
+
class << self
|
|
12
|
+
attr_accessor :service_name
|
|
13
|
+
|
|
14
|
+
def rpc_descs
|
|
15
|
+
rpcs.transform_values do |rpc|
|
|
16
|
+
Descriptor.new(rpc.request_class, rpc.response_class, rpc.streaming)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Shapes protocol-grpc descriptors for the existing transport-independent router.
|
|
22
|
+
# @api private
|
|
23
|
+
Stream = Struct.new(:type)
|
|
24
|
+
Descriptor = Struct.new(:request_type, :response_type, :kind) do
|
|
25
|
+
def client_streamer? = kind == :client_streaming
|
|
26
|
+
def server_streamer? = kind == :server_streaming
|
|
27
|
+
def bidi_streamer? = kind == :bidirectional
|
|
28
|
+
def input = client_streamer? || bidi_streamer? ? Stream.new(request_type) : request_type
|
|
29
|
+
def output = server_streamer? || bidi_streamer? ? Stream.new(response_type) : response_type
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/gritz/async.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/condition"
|
|
4
|
+
require "protocol/http/body/wrapper"
|
|
5
|
+
|
|
6
|
+
module Gritz
|
|
7
|
+
module Transport
|
|
8
|
+
class Async
|
|
9
|
+
# Delays HTTP headers until the handler sends metadata, data or a final status.
|
|
10
|
+
# @api private
|
|
11
|
+
class Body < Protocol::HTTP::Body::Wrapper
|
|
12
|
+
def initialize(body)
|
|
13
|
+
super
|
|
14
|
+
@headers_ready = ::Async::Condition.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def release_headers
|
|
18
|
+
@released = true
|
|
19
|
+
@headers_ready.signal
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def length
|
|
23
|
+
@headers_ready.wait until @released
|
|
24
|
+
super
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/grpc/dispatcher"
|
|
4
|
+
require "async/grpc/service"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require_relative "call"
|
|
7
|
+
require_relative "body"
|
|
8
|
+
require_relative "input"
|
|
9
|
+
|
|
10
|
+
module Gritz
|
|
11
|
+
module Transport
|
|
12
|
+
class Async
|
|
13
|
+
# Reuses async-grpc's routing, framing, status and deadline implementation.
|
|
14
|
+
# @api private
|
|
15
|
+
class Bridge < ::Async::GRPC::Dispatcher
|
|
16
|
+
def initialize(adapter, routes, config:, logger:)
|
|
17
|
+
super()
|
|
18
|
+
@adapter = adapter
|
|
19
|
+
@routes = routes
|
|
20
|
+
@config = config
|
|
21
|
+
@logger = logger
|
|
22
|
+
routes.values.group_by(&:service).each do |name, descriptors|
|
|
23
|
+
interface = Class.new(Protocol::GRPC::Interface)
|
|
24
|
+
descriptors.each do |descriptor|
|
|
25
|
+
# Run unary handlers in child tasks too, so headers and cancellation
|
|
26
|
+
# can progress while the application is waiting for I/O.
|
|
27
|
+
interface.rpc(descriptor.name.to_sym, descriptor.input_type, descriptor.output_type, streaming: :bidirectional)
|
|
28
|
+
end
|
|
29
|
+
service = ::Async::GRPC::Service.new(interface, name)
|
|
30
|
+
descriptors.each { |descriptor| service.define_singleton_method(descriptor.action) { nil } }
|
|
31
|
+
register(service)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
protected
|
|
36
|
+
|
|
37
|
+
def invoke_service(_service, _method, input, output, protocol)
|
|
38
|
+
protocol.response.body = Body.new(output)
|
|
39
|
+
descriptor = @routes.fetch(protocol.request.path)
|
|
40
|
+
input = Input.new(input.body, limit: @config.max_receive_message_size, message_class: descriptor.input_type, encoding: input.encoding)
|
|
41
|
+
call = Call.new(method_descriptor: descriptor, protocol:, input:, output:, max_send_size: @config.max_send_message_size)
|
|
42
|
+
owner = ::Async::Task.current
|
|
43
|
+
monitor = owner.async do
|
|
44
|
+
sleep 0.01 until call.cancelled?
|
|
45
|
+
owner.stop
|
|
46
|
+
end
|
|
47
|
+
terminal_error = nil
|
|
48
|
+
begin
|
|
49
|
+
size = protocol.request.headers.each.sum { |key, value| key.bytesize + Array(value).sum { |item| item.to_s.bytesize } + 32 }
|
|
50
|
+
raise Errors::ResourceExhausted, "request metadata exceeds max_metadata_size" if size > @config.max_metadata_size
|
|
51
|
+
|
|
52
|
+
@adapter.dispatch(call)
|
|
53
|
+
rescue Gritz::Error => e
|
|
54
|
+
terminal_error = e
|
|
55
|
+
raise
|
|
56
|
+
ensure
|
|
57
|
+
begin
|
|
58
|
+
call.finish(terminal_error)
|
|
59
|
+
ensure
|
|
60
|
+
monitor.stop
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def assign_status(protocol, error)
|
|
68
|
+
unless error.is_a?(Gritz::Error) || error.is_a?(::Async::GRPC::DeadlineExceededError) || error.is_a?(Protocol::GRPC::Error)
|
|
69
|
+
error_id = SecureRandom.uuid
|
|
70
|
+
@logger.error("#{error_id}: #{error.full_message}")
|
|
71
|
+
error = Errors::Internal.new("internal error (#{error_id})", metadata: { "error-id" => error_id })
|
|
72
|
+
protocol.response.headers.add("error-id", error_id)
|
|
73
|
+
end
|
|
74
|
+
super
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def status_for(error)
|
|
78
|
+
return [error.grpc_code, error.message] if error.is_a?(Gritz::Error)
|
|
79
|
+
|
|
80
|
+
super
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "google/rpc/status_pb"
|
|
5
|
+
require "google/protobuf/well_known_types"
|
|
6
|
+
|
|
7
|
+
module Gritz
|
|
8
|
+
module Transport
|
|
9
|
+
class Async
|
|
10
|
+
# Maps protocol-grpc streams and metadata onto the common RPC contract.
|
|
11
|
+
# @api private
|
|
12
|
+
class Call
|
|
13
|
+
include Gritz::Call
|
|
14
|
+
|
|
15
|
+
attr_reader :method_descriptor, :trailing_metadata, :deadline
|
|
16
|
+
|
|
17
|
+
def initialize(method_descriptor:, protocol:, input:, output:, max_send_size:)
|
|
18
|
+
@method_descriptor = method_descriptor
|
|
19
|
+
@protocol = protocol
|
|
20
|
+
@input = input
|
|
21
|
+
@output = output
|
|
22
|
+
@max_send_size = max_send_size
|
|
23
|
+
@deadline = Time.now + protocol.deadline.remaining if protocol.deadline
|
|
24
|
+
@trailing_metadata = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def metadata = @protocol.metadata.transform_values { |value| value.is_a?(Array) && value.one? ? value.first : value }
|
|
28
|
+
def cancelled? = @protocol.request.stream.closed?
|
|
29
|
+
def read = @input.read
|
|
30
|
+
|
|
31
|
+
def write(message)
|
|
32
|
+
if method_descriptor.output_type.encode(message).bytesize > @max_send_size
|
|
33
|
+
raise Errors::ResourceExhausted, "response message exceeds max_send_message_size"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@protocol.response.body.release_headers
|
|
37
|
+
@output.write(message)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def peer
|
|
41
|
+
address = @protocol.request.remote_address
|
|
42
|
+
return unless address
|
|
43
|
+
|
|
44
|
+
"#{address.ipv6? ? 'ipv6' : 'ipv4'}:#{address.ip_address}:#{address.ip_port}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def send_initial_metadata(metadata = {})
|
|
48
|
+
merge_initial_metadata(metadata)
|
|
49
|
+
@protocol.response.body.release_headers
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def merge_initial_metadata(metadata = {})
|
|
53
|
+
headers = @protocol.response.headers
|
|
54
|
+
metadata.each do |key, value|
|
|
55
|
+
headers.delete(key)
|
|
56
|
+
Array(value).each { |item| headers.add(key, encode_metadata(key, item)) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def finish(error = nil)
|
|
61
|
+
metadata = trailing_metadata.merge(error.is_a?(Gritz::Error) ? error.metadata : {})
|
|
62
|
+
if error.is_a?(Gritz::Error) && !error.details.empty?
|
|
63
|
+
details = error.details.map { |detail| detail.is_a?(Google::Protobuf::Any) ? detail : Google::Protobuf::Any.pack(detail) }
|
|
64
|
+
metadata["grpc-status-details-bin"] = Google::Rpc::Status.encode(
|
|
65
|
+
Google::Rpc::Status.new(code: error.grpc_code, message: error.message, details:)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
headers = @protocol.response.headers
|
|
69
|
+
headers.trailer!
|
|
70
|
+
metadata.each { |key, value| Array(value).each { |item| headers.add(key, encode_metadata(key, item)) } }
|
|
71
|
+
ensure
|
|
72
|
+
@protocol.response.body.release_headers
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def encode_metadata(key, value) = key.end_with?("-bin") ? Base64.strict_encode64(value) : value.to_s
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/grpc/body/readable"
|
|
4
|
+
|
|
5
|
+
module Gritz
|
|
6
|
+
module Transport
|
|
7
|
+
class Async
|
|
8
|
+
# Bounds both the wire frame and the inflated payload before protobuf decoding.
|
|
9
|
+
# @api private
|
|
10
|
+
class Input < Protocol::GRPC::Body::Readable
|
|
11
|
+
def initialize(body, limit:, **)
|
|
12
|
+
super(body, **)
|
|
13
|
+
@limit = limit
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def read
|
|
17
|
+
@prefix = true
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def read_exactly(length)
|
|
24
|
+
check_size!(length) unless @prefix
|
|
25
|
+
@prefix = false
|
|
26
|
+
super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def decompress(data)
|
|
30
|
+
return super unless %w[gzip deflate].include?(encoding)
|
|
31
|
+
|
|
32
|
+
inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + (encoding == "gzip" ? 16 : 0))
|
|
33
|
+
output = +"".b
|
|
34
|
+
inflater.inflate(data) do |chunk|
|
|
35
|
+
check_size!(output.bytesize + chunk.bytesize)
|
|
36
|
+
output << chunk
|
|
37
|
+
end
|
|
38
|
+
raise Errors::InvalidArgument, "truncated compressed request" unless inflater.finished?
|
|
39
|
+
|
|
40
|
+
output
|
|
41
|
+
rescue Zlib::Error
|
|
42
|
+
raise Errors::InvalidArgument, "invalid compressed request"
|
|
43
|
+
ensure
|
|
44
|
+
inflater&.close
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def check_size!(size)
|
|
48
|
+
raise Errors::ResourceExhausted, "request message exceeds max_receive_message_size" if size > @limit
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/http/protocol/http2"
|
|
4
|
+
|
|
5
|
+
module Gritz
|
|
6
|
+
module Transport
|
|
7
|
+
class Async
|
|
8
|
+
# Owns HTTP/2 connections so shutdown can send GOAWAY and retain active streams.
|
|
9
|
+
# @api private
|
|
10
|
+
class Server
|
|
11
|
+
class Connection < ::Async::HTTP::Protocol::HTTP2::Server
|
|
12
|
+
attr_reader :last_stream_id
|
|
13
|
+
|
|
14
|
+
def drain!
|
|
15
|
+
@last_stream_id = remote_stream_id
|
|
16
|
+
frame = Protocol::HTTP2::GoawayFrame.new
|
|
17
|
+
frame.pack(@last_stream_id, 0, "")
|
|
18
|
+
# Upstream send_goaway also closes the reader, losing active responses.
|
|
19
|
+
write_frame(frame)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A client may reset its stream while the handler is still completing.
|
|
24
|
+
module ResponseLifecycle
|
|
25
|
+
def send_response(response)
|
|
26
|
+
return response&.close if stream.closed?
|
|
27
|
+
|
|
28
|
+
super
|
|
29
|
+
rescue Protocol::HTTP2::ProtocolError
|
|
30
|
+
raise unless stream.closed?
|
|
31
|
+
|
|
32
|
+
response&.close
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(app, logger)
|
|
37
|
+
@app = app
|
|
38
|
+
@logger = logger
|
|
39
|
+
@connections = {}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def accept(peer, _address)
|
|
43
|
+
connection = Connection.new(::IO::Stream(peer))
|
|
44
|
+
connection.read_connection_preface(::Async::HTTP::Protocol::HTTP2::SERVER_SETTINGS)
|
|
45
|
+
connection.drain! if @stopping
|
|
46
|
+
connection.start_connection
|
|
47
|
+
@connections[connection] = true
|
|
48
|
+
connection.each do |request|
|
|
49
|
+
request.scheme ||= "http"
|
|
50
|
+
request.extend(ResponseLifecycle)
|
|
51
|
+
next if request.stream.closed?
|
|
52
|
+
|
|
53
|
+
if connection.last_stream_id && request.stream.id > connection.last_stream_id
|
|
54
|
+
# Decode frames normally to retain HPACK and flow-control state.
|
|
55
|
+
request.stream.send_reset_stream(Protocol::HTTP2::REFUSED_STREAM)
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@app.call(request)
|
|
60
|
+
end
|
|
61
|
+
rescue IOError, SystemCallError, Protocol::HTTP2::Error => e
|
|
62
|
+
@logger.debug("Async connection closed: #{e.class}")
|
|
63
|
+
ensure
|
|
64
|
+
@connections.delete(connection)
|
|
65
|
+
connection&.close
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def stop(deadline:)
|
|
69
|
+
@stopping = true
|
|
70
|
+
@connections.keys.each do |connection| # rubocop:disable Style/HashEachMethods -- IO can yield while other connections change.
|
|
71
|
+
connection.drain! unless connection.closed?
|
|
72
|
+
rescue IOError, SystemCallError, Protocol::HTTP2::Error
|
|
73
|
+
connection.close
|
|
74
|
+
end
|
|
75
|
+
until @connections.empty? || Time.now >= deadline
|
|
76
|
+
break if @connections.keys.all? { |connection| connection.streams.empty? }
|
|
77
|
+
|
|
78
|
+
sleep 0.001
|
|
79
|
+
end
|
|
80
|
+
@connections.keys.each(&:close) # rubocop:disable Style/HashEachMethods -- Closing can yield and change connections.
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set" # rubocop:disable Lint/RedundantRequireStatement -- required by Ruby 3.3
|
|
4
|
+
require "socket"
|
|
5
|
+
require "async"
|
|
6
|
+
require_relative "async/bridge"
|
|
7
|
+
require_relative "async/server"
|
|
8
|
+
|
|
9
|
+
module Gritz
|
|
10
|
+
module Transport
|
|
11
|
+
# Runs RPC controllers in fibers without the grpc C extension.
|
|
12
|
+
# @api public
|
|
13
|
+
class Async
|
|
14
|
+
def self.capabilities = Set[:unary, :client_streaming, :server_streaming, :bidi, :reuseport, :inherited_fd, :fiber].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(config:, dispatcher:, logger:)
|
|
17
|
+
@config = config
|
|
18
|
+
@dispatcher = dispatcher
|
|
19
|
+
@logger = logger
|
|
20
|
+
@lock = Mutex.new
|
|
21
|
+
@inflight = {}.compare_by_identity
|
|
22
|
+
@requests_total = @rejected_total = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The caller retains ownership of an inherited socket; only its duplicate is closed.
|
|
26
|
+
# @return [Integer] the selected TCP port
|
|
27
|
+
def bind(listener_spec = @config.bind)
|
|
28
|
+
raise ArgumentError, "transport is already bound" if @listener
|
|
29
|
+
raise ArgumentError, "at least one controller must be registered" if @dispatcher.router.routes.empty?
|
|
30
|
+
raise ConfigurationError, "Async reflection is not supported" if @config.reflection
|
|
31
|
+
raise ConfigurationError, "Async TLS is not supported" unless @config.tls.empty?
|
|
32
|
+
|
|
33
|
+
%i[max_connection_age max_connection_age_grace keepalive_time keepalive_permit_without_calls].each do |setting|
|
|
34
|
+
unless @config.public_send(setting) == Configuration::DEFAULTS.fetch(setting)
|
|
35
|
+
raise ConfigurationError, "#{setting} is only supported by the Native adapter"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if listener_spec.respond_to?(:accept)
|
|
40
|
+
@listener = listener_spec.dup
|
|
41
|
+
else
|
|
42
|
+
@listener = Supervisor::Listener.bind(listener_spec, reuseport: @config.listener_strategy == :reuseport)
|
|
43
|
+
end
|
|
44
|
+
@listener.local_address.ip_port
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def start
|
|
48
|
+
raise ArgumentError, "transport is stopped" if @stopped
|
|
49
|
+
raise ArgumentError, "bind must be called before start" unless @listener
|
|
50
|
+
raise ArgumentError, "transport is already started" if @thread
|
|
51
|
+
|
|
52
|
+
@control_r, @control_w = IO.pipe
|
|
53
|
+
ready = Queue.new
|
|
54
|
+
@thread = Thread.new do
|
|
55
|
+
Async do |root|
|
|
56
|
+
bridge = Bridge.new(self, @dispatcher.router.routes, config: @config, logger: @logger)
|
|
57
|
+
@server = Server.new(bridge, @logger)
|
|
58
|
+
acceptor = root.async do
|
|
59
|
+
loop do
|
|
60
|
+
peer, address = @listener.accept
|
|
61
|
+
root.async(peer, address) do |_task, client, remote|
|
|
62
|
+
@server.accept(client, remote)
|
|
63
|
+
ensure
|
|
64
|
+
client.close unless client.closed?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
ready << true
|
|
69
|
+
@control_r.read(1)
|
|
70
|
+
acceptor.stop
|
|
71
|
+
@listener.close
|
|
72
|
+
@server.stop(deadline: @stop_deadline)
|
|
73
|
+
ensure
|
|
74
|
+
root.children.to_a.each(&:stop)
|
|
75
|
+
end
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
ready << e
|
|
78
|
+
raise
|
|
79
|
+
end
|
|
80
|
+
result = ready.pop(timeout: 5)
|
|
81
|
+
raise(result || "Async server did not start within 5 seconds") unless result == true
|
|
82
|
+
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def running? = @thread&.alive? && !@stopped
|
|
87
|
+
def wait = @thread&.value
|
|
88
|
+
|
|
89
|
+
def stop(deadline:)
|
|
90
|
+
drain!
|
|
91
|
+
return if @stopped
|
|
92
|
+
|
|
93
|
+
@stopped = true
|
|
94
|
+
@stop_deadline = deadline
|
|
95
|
+
if @thread&.alive?
|
|
96
|
+
@control_w.write(".")
|
|
97
|
+
wait
|
|
98
|
+
end
|
|
99
|
+
ensure
|
|
100
|
+
@listener&.close unless @listener&.closed?
|
|
101
|
+
@control_r&.close unless @control_r&.closed?
|
|
102
|
+
@control_w&.close unless @control_w&.closed?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def kill = stop(deadline: Time.now)
|
|
106
|
+
|
|
107
|
+
def drain!
|
|
108
|
+
@draining = true
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def update_health(ready:, checks: {}) = ready && checks.values.all? && !@draining
|
|
113
|
+
|
|
114
|
+
def refresh_health
|
|
115
|
+
checks = @config.health_checks.transform_values do |check|
|
|
116
|
+
check.call ? true : false
|
|
117
|
+
rescue StandardError
|
|
118
|
+
false
|
|
119
|
+
end
|
|
120
|
+
update_health(ready: running?, checks:)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def stats
|
|
124
|
+
@lock.synchronize do
|
|
125
|
+
oldest = @inflight.values.min
|
|
126
|
+
{ inflight: @inflight.size, busy: @inflight.size, capacity: @config.threads + @config.max_waiting_requests,
|
|
127
|
+
rejected_total: @rejected_total, requests_total: @requests_total,
|
|
128
|
+
oldest_inflight_age: oldest ? Process.clock_gettime(Process::CLOCK_MONOTONIC) - oldest : 0 }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @api private
|
|
133
|
+
def dispatch(call)
|
|
134
|
+
admitted = @lock.synchronize do
|
|
135
|
+
if @inflight.size >= @config.threads + @config.max_waiting_requests
|
|
136
|
+
@rejected_total += 1
|
|
137
|
+
false
|
|
138
|
+
else
|
|
139
|
+
@inflight[call] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
140
|
+
@requests_total += 1
|
|
141
|
+
true
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
raise Errors::ResourceExhausted, "Async concurrency limit reached" unless admitted
|
|
145
|
+
|
|
146
|
+
result = @dispatcher.call(call)
|
|
147
|
+
call.write(result) unless call.method_descriptor.server_streaming?
|
|
148
|
+
ensure
|
|
149
|
+
@lock.synchronize { @inflight.delete(call) } if admitted
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: gritz-async
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: async-grpc
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.10.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.10.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: async-http
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.105.0
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.105.0
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: googleapis-common-protos-types
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.20'
|
|
47
|
+
- - "<"
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '2'
|
|
50
|
+
type: :runtime
|
|
51
|
+
prerelease: false
|
|
52
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
53
|
+
requirements:
|
|
54
|
+
- - ">="
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '1.20'
|
|
57
|
+
- - "<"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '2'
|
|
60
|
+
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: gritz-core
|
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - '='
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: 0.6.0
|
|
67
|
+
type: :runtime
|
|
68
|
+
prerelease: false
|
|
69
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - '='
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: 0.6.0
|
|
74
|
+
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: protocol-grpc
|
|
76
|
+
requirement: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - "~>"
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: 0.17.0
|
|
81
|
+
type: :runtime
|
|
82
|
+
prerelease: false
|
|
83
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - "~>"
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: 0.17.0
|
|
88
|
+
- !ruby/object:Gem::Dependency
|
|
89
|
+
name: protocol-http2
|
|
90
|
+
requirement: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - "~>"
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: 0.29.1
|
|
95
|
+
type: :runtime
|
|
96
|
+
prerelease: false
|
|
97
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
98
|
+
requirements:
|
|
99
|
+
- - "~>"
|
|
100
|
+
- !ruby/object:Gem::Version
|
|
101
|
+
version: 0.29.1
|
|
102
|
+
description: The Async gRPC adapter for Gritz, with inherited listeners and transport-independent
|
|
103
|
+
controllers.
|
|
104
|
+
email:
|
|
105
|
+
- t.yudai92@gmail.com
|
|
106
|
+
executables:
|
|
107
|
+
- gritz
|
|
108
|
+
extensions: []
|
|
109
|
+
extra_rdoc_files: []
|
|
110
|
+
files:
|
|
111
|
+
- CHANGELOG.md
|
|
112
|
+
- LICENSE.txt
|
|
113
|
+
- README.md
|
|
114
|
+
- exe/gritz
|
|
115
|
+
- lib/gritz/async.rb
|
|
116
|
+
- lib/gritz/async/service.rb
|
|
117
|
+
- lib/gritz/async/version.rb
|
|
118
|
+
- lib/gritz/transport/async.rb
|
|
119
|
+
- lib/gritz/transport/async/body.rb
|
|
120
|
+
- lib/gritz/transport/async/bridge.rb
|
|
121
|
+
- lib/gritz/transport/async/call.rb
|
|
122
|
+
- lib/gritz/transport/async/input.rb
|
|
123
|
+
- lib/gritz/transport/async/server.rb
|
|
124
|
+
homepage: https://github.com/gritzrpc/gritz-async
|
|
125
|
+
licenses:
|
|
126
|
+
- MIT
|
|
127
|
+
metadata:
|
|
128
|
+
allowed_push_host: https://rubygems.org
|
|
129
|
+
source_code_uri: https://github.com/gritzrpc/gritz-async
|
|
130
|
+
changelog_uri: https://github.com/gritzrpc/gritz-async/blob/main/CHANGELOG.md
|
|
131
|
+
rubygems_mfa_required: 'true'
|
|
132
|
+
rdoc_options: []
|
|
133
|
+
require_paths:
|
|
134
|
+
- lib
|
|
135
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
136
|
+
requirements:
|
|
137
|
+
- - ">="
|
|
138
|
+
- !ruby/object:Gem::Version
|
|
139
|
+
version: '3.3'
|
|
140
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - ">="
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '0'
|
|
145
|
+
requirements: []
|
|
146
|
+
rubygems_version: 4.0.16
|
|
147
|
+
specification_version: 4
|
|
148
|
+
summary: Fiber-based gRPC transport for Gritz
|
|
149
|
+
test_files: []
|