Skip to content

Commit 05d6f27

Browse files
Add support for XDS v3.
1 parent e95816b commit 05d6f27

229 files changed

Lines changed: 21204 additions & 1859 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-xds.yaml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Test xDS
2+
3+
on: [push, pull_request]
4+
5+
permissions:
6+
contents: read
7+
8+
env:
9+
CONSOLE_OUTPUT: XTerm
10+
11+
jobs:
12+
test:
13+
name: ${{matrix.ruby}} on ${{matrix.os}}
14+
runs-on: ${{matrix.os}}-latest
15+
continue-on-error: ${{matrix.experimental}}
16+
17+
strategy:
18+
matrix:
19+
os:
20+
- ubuntu
21+
22+
ruby:
23+
- "3.3"
24+
- "3.4"
25+
- "4.0"
26+
27+
experimental: [false]
28+
29+
steps:
30+
- uses: actions/checkout@v6
31+
32+
- name: Run tests
33+
timeout-minutes: 15
34+
env:
35+
RUBY_VERSION: ${{matrix.ruby}}
36+
run: docker compose -f xds/docker-compose.yaml up --exit-code-from tests

async-grpc.gemspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Gem::Specification.new do |spec|
2424

2525
spec.required_ruby_version = ">= 3.3"
2626

27+
spec.add_dependency "async", ">= 2.38.0"
2728
spec.add_dependency "async-http"
2829
spec.add_dependency "protocol-http", "~> 0.60"
2930
spec.add_dependency "protocol-grpc", "~> 0.11.0"

bake/async/grpc/xds.rb

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025-2026, by Samuel Williams.
5+
6+
# Generate Ruby protobuf classes from Envoy .proto files
7+
# @parameter proto_dir [String] Directory containing .proto files (default: "proto")
8+
# @parameter output_dir [String] Output directory for generated Ruby files (default: "lib")
9+
def generate_protos(proto_dir: "proto", output_dir: "lib")
10+
require "fileutils"
11+
12+
proto_dir = File.expand_path(proto_dir)
13+
output_dir = File.expand_path(output_dir)
14+
15+
# Core discovery service files (most important)
16+
discovery_files = [
17+
"envoy/service/discovery/v3/discovery.proto",
18+
"envoy/service/discovery/v3/ads.proto"
19+
]
20+
21+
# Core config files needed for discovery
22+
config_files = [
23+
"envoy/config/core/v3/base.proto",
24+
"envoy/config/core/v3/address.proto",
25+
"envoy/config/core/v3/config_source.proto",
26+
"envoy/config/cluster/v3/cluster.proto",
27+
"envoy/config/endpoint/v3/endpoint.proto"
28+
]
29+
30+
# Google protobuf well-known types
31+
google_files = [
32+
"google/protobuf/any.proto",
33+
"google/protobuf/duration.proto",
34+
"google/protobuf/timestamp.proto",
35+
"google/protobuf/struct.proto",
36+
"google/protobuf/empty.proto",
37+
"google/protobuf/wrappers.proto",
38+
"google/rpc/status.proto"
39+
]
40+
41+
all_files = discovery_files + config_files + google_files
42+
43+
# Create output directories
44+
FileUtils.mkdir_p(output_dir)
45+
46+
# Generate Ruby code
47+
all_files.each do |proto_file|
48+
full_path = File.join(proto_dir, proto_file)
49+
next unless File.exist?(full_path)
50+
51+
Console.info{"Generating #{proto_file}..."}
52+
53+
system(
54+
"protoc",
55+
"--ruby_out=#{output_dir}",
56+
"--proto_path=#{proto_dir}",
57+
"--proto_path=#{File.join(proto_dir, 'google')}",
58+
full_path,
59+
out: File::NULL,
60+
err: File::NULL
61+
) or begin
62+
Console.warn{"Failed to generate #{proto_file} (may have missing dependencies)"}
63+
end
64+
end
65+
66+
# Count generated files
67+
generated = Dir.glob(File.join(output_dir, "**/*_pb.rb")).count
68+
69+
Console.info{"Generated #{generated} protobuf Ruby files in #{output_dir}"}
70+
end
71+
72+
# Generate all protobuf files (including optional dependencies)
73+
# This will attempt to generate all .proto files, even if some fail
74+
# @parameter proto_dir [String] Directory containing .proto files (default: "proto")
75+
# @parameter output_dir [String] Output directory for generated Ruby files (default: "lib")
76+
def generate_all_protos(proto_dir: "proto", output_dir: "lib")
77+
require "fileutils"
78+
79+
proto_dir = File.expand_path(proto_dir)
80+
output_dir = File.expand_path(output_dir)
81+
82+
# Find all .proto files
83+
proto_files = Dir.glob(File.join(proto_dir, "**/*.proto"))
84+
85+
Console.info{"Found #{proto_files.count} .proto files"}
86+
87+
# Generate each file
88+
success_count = 0
89+
fail_count = 0
90+
91+
proto_files.each do |proto_file|
92+
relative_path = proto_file.sub(/^#{proto_dir}\//, "")
93+
94+
Console.debug{"Generating #{relative_path}..."}
95+
96+
if system(
97+
"protoc",
98+
"--ruby_out=#{output_dir}",
99+
"--proto_path=#{proto_dir}",
100+
"--proto_path=#{File.join(proto_dir, 'google')}",
101+
proto_file,
102+
out: File::NULL,
103+
err: File::NULL
104+
)
105+
success_count += 1
106+
else
107+
fail_count += 1
108+
Console.debug{"Failed: #{relative_path}"}
109+
end
110+
end
111+
112+
# Count generated files
113+
generated = Dir.glob(File.join(output_dir, "**/*_pb.rb")).count
114+
115+
Console.info{"Generated #{generated} protobuf Ruby files (#{success_count} succeeded, #{fail_count} failed)"}
116+
end

code.md

Whitespace-only changes.

lib/async/grpc/client.rb

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,12 @@ def call(request)
113113
# @parameter metadata [Hash] Custom metadata headers
114114
# @parameter timeout [Numeric | Nil] Optional timeout in seconds
115115
# @parameter encoding [String | Nil] Optional compression encoding
116+
# @parameter initial [Object | Array] Optional initial message(s) to send with the request body for bidirectional streaming (avoids deadlock when server waits for first message)
116117
# @yields {|input, output| ...} Block for streaming calls
117118
# @returns [Object | Protocol::GRPC::Body::ReadableBody] Response message or readable body for streaming
118119
# @raises [ArgumentError] If method is unknown or streaming type is invalid
119120
# @raises [Protocol::GRPC::Error] If the gRPC call fails
120-
def invoke(service, method, request = nil, metadata: {}, timeout: nil, encoding: nil, &block)
121+
def invoke(service, method, request = nil, metadata: {}, timeout: nil, encoding: nil, initial: nil, &block)
121122
rpc = service.class.lookup_rpc(method)
122123
raise ArgumentError, "Unknown method: #{method}" unless rpc
123124

@@ -141,7 +142,7 @@ def invoke(service, method, request = nil, metadata: {}, timeout: nil, encoding:
141142
when :client_streaming
142143
client_streaming_call(path, headers, request_class, response_class, encoding, &block)
143144
when :bidirectional
144-
bidirectional_call(path, headers, request_class, response_class, encoding, &block)
145+
bidirectional_call(path, headers, request_class, response_class, encoding, initial: initial, &block)
145146
else
146147
raise ArgumentError, "Unknown streaming type: #{streaming}"
147148
end
@@ -273,14 +274,16 @@ def client_streaming_call(path, headers, request_class, response_class, encoding
273274
# @parameter request_class [Class] Request message class
274275
# @parameter response_class [Class] Response message class
275276
# @parameter encoding [String | Nil] Compression encoding
277+
# @parameter initial [Object | Array | Nil] Optional initial message(s) to send with the request body (avoids deadlock when server waits for first message)
276278
# @yields {|input, output| ...} Block to handle bidirectional streaming
277279
# @returns [Protocol::GRPC::Body::ReadableBody] Readable body for streaming messages
278280
# @raises [Protocol::GRPC::Error] If the gRPC call fails
279-
def bidirectional_call(path, headers, request_class, response_class, encoding, &block)
281+
def bidirectional_call(path, headers, request_class, response_class, encoding, initial: nil, &block)
280282
body = Protocol::GRPC::Body::WritableBody.new(
281283
encoding: encoding,
282284
message_class: request_class
283285
)
286+
Array(initial).each{|message| body.write(message)}
284287

285288
http_request = Protocol::HTTP::Request["POST", path, headers, body]
286289
response = call(http_request)

lib/async/grpc/stub.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,14 @@ def method_missing(method_name, *args, **options, &block)
4747
# Extract request from args (first positional argument):
4848
request = args.first
4949

50-
# Extract metadata, timeout, encoding from options:
50+
# Extract metadata, timeout, encoding, initial from options:
5151
metadata = options.delete(:metadata) || {}
5252
timeout = options.delete(:timeout)
5353
encoding = options.delete(:encoding)
54+
initial = options.delete(:initial)
5455

5556
# Delegate to client.invoke with PascalCase method name (for interface lookup):
56-
@client.invoke(@interface, interface_method_name, request, metadata: metadata, timeout: timeout, encoding: encoding, &block)
57+
@client.invoke(@interface, interface_method_name, request, metadata: metadata, timeout: timeout, encoding: encoding, initial: initial, &block)
5758
else
5859
super
5960
end

lib/async/grpc/xds.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025-2026, by Samuel Williams.
5+
6+
# Load order matters - Context must be loaded before Client
7+
require_relative "xds/resource_cache"
8+
require_relative "xds/resources"
9+
require_relative "xds/ads_stream"
10+
require_relative "xds/discovery_client"
11+
require_relative "xds/health_checker"
12+
require_relative "xds/load_balancer"
13+
require_relative "xds/context"
14+
require_relative "xds/client"
15+
16+
module Async
17+
module GRPC
18+
# xDS (Discovery Service) support for dynamic service discovery and configuration
19+
#
20+
# Provides dynamic service discovery and load balancing for gRPC clients
21+
# using the xDS (Discovery Service) protocol.
22+
#
23+
# @example Basic usage
24+
# require "async/grpc/xds"
25+
#
26+
# bootstrap = {
27+
# "xds_servers" => [{"server_uri" => "xds-control-plane:18000"}],
28+
# "node" => {"id" => "client-1", "cluster" => "test"}
29+
# }
30+
#
31+
# xds_client = Async::GRPC::XDS::Client.new("myservice", bootstrap: bootstrap)
32+
# stub = xds_client.stub(MyServiceInterface, "myservice")
33+
# response = stub.say_hello(request)
34+
module XDS
35+
end
36+
end
37+
end

lib/async/grpc/xds/ads_stream.rb

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025-2026, by Samuel Williams.
5+
6+
require "async"
7+
require "async/grpc/client"
8+
require "envoy/service/discovery/v3/aggregated_discovery_service"
9+
require "envoy/service/discovery/v3/discovery_pb"
10+
require "envoy/config/core/v3/base_pb"
11+
12+
module Async
13+
module GRPC
14+
module XDS
15+
# Encapsulates a single ADS (Aggregated Discovery Service) bidirectional stream.
16+
# Owns the stream lifecycle and delegates events to a delegate object.
17+
class ADSStream
18+
# Interface for ADSStream delegates. Implement these methods to receive stream events.
19+
module Delegate
20+
# Called when a DiscoveryResponse is received from the server.
21+
# @parameter response [Envoy::Service::Discovery::V3::DiscoveryResponse] The discovery response
22+
# @parameter stream [ADSStream] The stream instance; use stream.send(request) to send ACKs or new requests
23+
def discovery_response(response, stream)
24+
end
25+
end
26+
27+
def initialize(client, node, delegate:)
28+
@client = client
29+
@node = node
30+
@delegate = delegate
31+
@body = nil
32+
end
33+
34+
# Send a DiscoveryRequest on the stream. Call from within discovery_response to send ACKs.
35+
# @parameter request [Envoy::Service::Discovery::V3::DiscoveryRequest] The request to send
36+
def send(request)
37+
@body&.write(request)
38+
end
39+
40+
# Run the ADS stream. Blocks until the stream completes or errors.
41+
# @parameter initial [Object | Array | Nil] Initial message(s) to send (defaults to node-only request if nil/empty)
42+
def run(initial: nil)
43+
service = Envoy::Service::Discovery::V3::AggregatedDiscoveryService.new(
44+
"envoy.service.discovery.v3.AggregatedDiscoveryService"
45+
)
46+
47+
initial = Array(initial).any? ? initial : [Envoy::Service::Discovery::V3::DiscoveryRequest.new(node: @node)]
48+
49+
@client.invoke(service, :StreamAggregatedResources, nil, initial: initial) do |body, readable_body|
50+
@body = body
51+
@delegate.stream_opened(self) if @delegate.respond_to?(:stream_opened)
52+
53+
begin
54+
readable_body.each do |response|
55+
@delegate.discovery_response(response, self)
56+
end
57+
ensure
58+
@delegate.stream_closed(self) if @delegate.respond_to?(:stream_closed)
59+
@body = nil
60+
end
61+
end
62+
rescue => error
63+
@delegate.stream_error(self, error) if @delegate.respond_to?(:stream_error)
64+
Console.error(self, "Failed while streaming updates!", exception: error)
65+
raise
66+
end
67+
end
68+
end
69+
end
70+
end

0 commit comments

Comments
 (0)