Skip to content

Latest commit

 

History

History
309 lines (231 loc) · 7.74 KB

File metadata and controls

309 lines (231 loc) · 7.74 KB

Integrating async-grpc with Google Cloud Spanner

Current State Analysis

How ruby-spanner Uses gRPC

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

Key Dependencies

  1. gRPC C Extension (grpc gem)

    • GRPC::Core::Channel - connection management
    • GRPC::Core::ChannelCredentials - TLS/SSL setup
    • GRPC::Core::CallCredentials - per-call auth (OAuth2 tokens)
  2. Generated Client Stubs (from google-cloud-spanner-v1 gem)

    • Google::Cloud::Spanner::V1::Spanner::Client
    • Generated by protoc with grpc plugin
    • Expects a gRPC channel as credentials

Replacement Strategy

Option 1: Drop-In Channel Replacement (Most Feasible)

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
end

Option 2: Regenerate Client Stubs (More Invasive)

Instead 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::Client

Then 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
end

Option 3: Monkey-Patch Mock Interface (Testing/Development Only)

Use 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)

Required Interface

To make this work, Async::GRPC::Client would need to support:

1. Raw Binary Messages

# 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_binary

2. Marshal/Unmarshal Callbacks

client.unary(
	service,
	method,
	request,
	marshal: ->(obj){obj.to_proto},
	unmarshal: ->(data){MyReply.decode(data)}
)

3. Compatible Metadata/Headers

Google's stubs expect specific metadata format (OAuth2 tokens, quota project, etc.)

Recommendation

Option 1 (Channel Adapter) is most feasible because:

  1. ✅ No need to regenerate all Google API stubs
  2. ✅ Works with existing google-cloud-spanner-v1 gem
  3. ✅ Minimal changes to Spanner gem
  4. ✅ Can be done incrementally (test with one RPC at a time)
  5. ✅ Falls back to standard gRPC if issues arise

Implementation Plan

Phase 1: Proof of Concept

  1. Implement Async::GRPC::ChannelAdapter
  2. Test with simple unary RPC (e.g., CreateSession)
  3. Verify it works end-to-end

Phase 2: Full Interface

  1. Implement all four RPC types (unary, client streaming, server streaming, bidirectional)
  2. Handle auth metadata properly
  3. Support deadlines and cancellation

Phase 3: Production Ready

  1. Handle all gRPC edge cases
  2. Proper error mapping
  3. Connection pooling
  4. Performance testing

Challenges

1. Generated Stub Format

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

2. Authentication

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.

3. Retry Logic

Google's client has sophisticated retry logic:

  • Exponential backoff
  • Per-method retry policies
  • Idempotency detection

We'd need to preserve this behavior.

4. Observability

Google's clients have built-in:

  • OpenTelemetry tracing
  • Metrics/logging
  • Quota tracking

Next Steps

  1. Investigate Gapic internals: Look at how google-cloud-spanner-v1 generated code works
  2. Find hook points: Identify where we can inject our channel
  3. Build minimal adapter: Implement just enough to make one RPC work
  4. Benchmark: Compare performance async-grpc vs standard gRPC

Benefits if Successful

  • ✅ 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)

Risks

  • ❌ 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