Looking at google-cloud-spanner/lib/google/cloud/spanner/service.rb:
def channel
require "grpc"
GRPC::Core::Channel.new host, chan_args, chan_creds
end
def chan_creds
return credentials if insecure?
require "grpc"
GRPC::Core::ChannelCredentials.new.compose \
GRPC::Core::CallCredentials.new credentials.client.updater_proc
end
def service
return mocked_service if mocked_service
@service ||=
V1::Spanner::Client.new do |config|
config.credentials = channel # <-- Passes gRPC channel
config.quota_project = @quota_project
config.timeout = timeout if timeout
config.endpoint = host if host
config.universe_domain = @universe_domain
config.lib_name = lib_name_with_prefix
config.lib_version = Google::Cloud::Spanner::VERSION
config.metadata = { "google-cloud-resource-prefix" => "projects/#{@project}" }
end
end-
gRPC C Extension (
grpcgem)GRPC::Core::Channel- connection managementGRPC::Core::ChannelCredentials- TLS/SSL setupGRPC::Core::CallCredentials- per-call auth (OAuth2 tokens)
-
Generated Client Stubs (from
google-cloud-spanner-v1gem)Google::Cloud::Spanner::V1::Spanner::Client- Generated by
protocwithgrpcplugin - Expects a gRPC channel as credentials
Create an adapter that implements the GRPC::Core::Channel interface:
module Async
module GRPC
# Adapter that makes Async::GRPC::Client compatible with
# Google's generated gRPC client stubs
class ChannelAdapter
def initialize(endpoint, channel_args = {}, channel_creds = nil)
@endpoint = endpoint
@client = Async::GRPC::Client.new(endpoint)
@channel_creds = channel_creds
end
# Called by generated stubs to make RPC calls
# Must implement the gRPC::Core::Channel interface
def request_response(path, request, marshal, unmarshal, deadline: nil, metadata: {})
# Parse service/method from path
# path format: "/google.spanner.v1.Spanner/ExecuteStreamingSql"
parts = path.split("/").last(2)
service = parts[0].split(".").last
method = parts[1]
# Add auth metadata
if @channel_creds
auth_metadata = @channel_creds.call(method)
metadata.merge!(auth_metadata)
end
# Marshal request
request_data = marshal.call(request)
# Make the call
response_data = Async do
@client.unary(
service,
method,
request_data,
metadata: metadata,
timeout: deadline
)
end.wait
# Unmarshal response
unmarshal.call(response_data)
end
# For server-streaming RPCs
def request_stream(path, request, marshal, unmarshal, deadline: nil, metadata: {})
# Similar but returns enumerator
Enumerator.new do |yielder|
Async do
@client.server_streaming do |response_data|
yielder << unmarshal.call(response_data)
end
end.wait
end
end
# For client-streaming RPCs
def stream_request(path, marshal, unmarshal, deadline: nil, metadata: {})
# Returns [input_stream, output_future]
end
# For bidirectional streaming RPCs
def stream_stream(path, marshal, unmarshal, deadline: nil, metadata: {})
# Returns [input_stream, output_enumerator]
end
def close
@client.close
end
end
end
endInstead of using Google's generated stubs, regenerate them with our own generator:
# Generate Spanner service stubs using protocol-grpc
bake protocol:grpc:generate google/spanner/v1/spanner.proto
# This would generate:
# - lib/google/spanner/v1/spanner_grpc.rb (client stubs)
# - Compatible with Async::GRPC::ClientThen modify service.rb:
require "google/spanner/v1/spanner_grpc" # Our generated stubs
def service
@service ||= begin
endpoint = Async::HTTP::Endpoint.parse("https://#{host}")
client = Async::GRPC::Client.new(endpoint)
Google::Spanner::V1::SpannerClient.new(client) # Our stub
end
endUse Spanner's built-in mocking capability:
# In service.rb
attr_accessor :mocked_service # Already exists!
# Our replacement
class AsyncGRPCSpannerService
def initialize(client)
@client = client
end
# Implement all Spanner RPC methods
def execute_streaming_sql(request, options = {})
# Call via async-grpc
end
def begin_transaction(request, options = {})
# ...
end
# ... implement all 20+ RPC methods
end
# Usage
service = Google::Cloud::Spanner::Service.new
service.mocked_service = AsyncGRPCSpannerService.new(async_grpc_client)To make this work, Async::GRPC::Client would need to support:
# Currently: pass protobuf objects
client.unary(service, method, request_object, response_class: MyReply)
# Need to support: pass raw binary
client.unary_binary(service, method, request_binary) # => response_binaryclient.unary(
service,
method,
request,
marshal: ->(obj){obj.to_proto},
unmarshal: ->(data){MyReply.decode(data)}
)Google's stubs expect specific metadata format (OAuth2 tokens, quota project, etc.)
Option 1 (Channel Adapter) is most feasible because:
- ✅ No need to regenerate all Google API stubs
- ✅ Works with existing
google-cloud-spanner-v1gem - ✅ Minimal changes to Spanner gem
- ✅ Can be done incrementally (test with one RPC at a time)
- ✅ Falls back to standard gRPC if issues arise
- Implement
Async::GRPC::ChannelAdapter - Test with simple unary RPC (e.g.,
CreateSession) - Verify it works end-to-end
- Implement all four RPC types (unary, client streaming, server streaming, bidirectional)
- Handle auth metadata properly
- Support deadlines and cancellation
- Handle all gRPC edge cases
- Proper error mapping
- Connection pooling
- Performance testing
Google's generated stubs use Gapic (Google API Client) framework, which has its own conventions. We'd need to understand:
- Exact method signatures expected
- How streaming responses are yielded
- Error handling patterns
Google Cloud uses:
- OAuth2 access tokens (refreshed automatically)
- Per-RPC credentials (added as metadata)
- Service account key files
Our adapter must support this auth flow.
Google's client has sophisticated retry logic:
- Exponential backoff
- Per-method retry policies
- Idempotency detection
We'd need to preserve this behavior.
Google's clients have built-in:
- OpenTelemetry tracing
- Metrics/logging
- Quota tracking
- Investigate Gapic internals: Look at how
google-cloud-spanner-v1generated code works - Find hook points: Identify where we can inject our channel
- Build minimal adapter: Implement just enough to make one RPC work
- Benchmark: Compare performance async-grpc vs standard gRPC
- ✅ Pure Ruby implementation (no C extension)
- ✅ Async-first design (better concurrency)
- ✅ Easier debugging (no C stack traces)
- ✅ Potentially better resource usage
- ✅ Could work on platforms where C extensions are problematic (e.g., JRuby, TruffleRuby)
- ❌ Incomplete gRPC protocol implementation
- ❌ Performance might be worse than C extension
- ❌ Maintenance burden (keep up with gRPC spec changes)
- ❌ Edge cases we haven't thought of