Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions google-cloud-spanner/Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ gem "minitest", "~> 5.25"
gem "minitest-autotest", "~> 1.0"
gem "minitest-focus", "~> 1.4"
gem "minitest-rg", "~> 5.3"
gem "mutex_m", "~> 0.3.0"
gem "pry", group: :development, require: false
gem "rake"
gem "redcarpet", "~> 3.0"
Expand Down
8 changes: 6 additions & 2 deletions google-cloud-spanner/lib/google/cloud/spanner.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

require "google-cloud-spanner"
require "google/cloud/spanner/project"
require "google/cloud/spanner/spanner_error"
require "google/cloud/spanner/request_id_interceptor"
require "google/cloud/config"
require "google/cloud/env"

Expand Down Expand Up @@ -96,7 +98,7 @@ module Spanner
def self.new project_id: nil, credentials: nil, scope: nil, timeout: nil,
endpoint: nil, project: nil, keyfile: nil,
emulator_host: nil, lib_name: nil, lib_version: nil,
enable_leader_aware_routing: true, universe_domain: nil
enable_leader_aware_routing: true, universe_domain: nil, process_id: nil
project_id ||= project || default_project_id
scope ||= configure.scope
timeout ||= configure.timeout
Expand All @@ -105,6 +107,7 @@ def self.new project_id: nil, credentials: nil, scope: nil, timeout: nil,
credentials ||= keyfile
lib_name ||= configure.lib_name
lib_version ||= configure.lib_version
interceptors = [RequestIdInterceptor.new(process_id: process_id)]
universe_domain ||= configure.universe_domain

if emulator_host
Expand All @@ -127,7 +130,8 @@ def self.new project_id: nil, credentials: nil, scope: nil, timeout: nil,
Spanner::Service.new(
project_id, credentials, quota_project: configure.quota_project,
host: endpoint, timeout: timeout, lib_name: lib_name,
lib_version: lib_version, universe_domain: universe_domain,
lib_version: lib_version, interceptors: interceptors,
universe_domain: universe_domain,
enable_leader_aware_routing: enable_leader_aware_routing
),
query_options: configure.query_options
Expand Down
1 change: 1 addition & 0 deletions google-cloud-spanner/lib/google/cloud/spanner/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@


require "google/cloud/errors"
require "google/cloud/spanner/spanner_error"

module Google
module Cloud
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


require "grpc"
require "securerandom"
require "mutex_m"
require "google/cloud/spanner/errors"

module Google
module Cloud
module Spanner
##
# RequestIdInterceptor is a GRPC interceptor class that captures all the rpc calls
# made by the GRPC layer inserting a new Header with a specific ID for debugging purposes.
#
class RequestIdInterceptor < GRPC::ClientInterceptor
@client_id_counter = 0
@client_mutex = Mutex.new
@channel_id_counter = 0
@channel_mutex = Mutex.new
@request_id_counter = 0
@request_id_mutex = Mutex.new
@process_id = nil
@process_id_mutex = Mutex.new

# @private
# Gets the next client ID and increments it.
#
# @return [Integer]
private_class_method def self.next_client_id
@client_mutex.synchronize do
@client_id_counter += 1
end
end

# @private
# Gets the next channel ID and increments it.
#
# @return [Integer]
private_class_method def self.next_channel_id
@channel_mutex.synchronize do
@channel_id_counter += 1
end
end

# @private
# Returns a process ID for the context of the request id header.
# A process ID is a Hex encoded 64 bit value
#
# @param [String, int] process_id A 64 bit value in Hex or Integer format
# @return [String]
private_class_method def self.get_process_id process_id = nil
@process_id_mutex.synchronize do
if process_id.nil? || !@process_id.nil?
return @process_id ||= (SecureRandom.hex 8)
end

case process_id
when Integer
if process_id >= 0 && process_id.bit_length <= 64
return process_id.to_s(16).rjust(16, "0")
end
when String
if process_id =~ /\A[0-9a-fA-F]{16}\z/
return process_id
end
end

raise ArgumentError, "process_id must be a 64-bit integer or 16-character hex string"
end
end

# Initializes a request_id_interceptor instance.
#
# @param [String, int] process_id A 64 bit value in Hex or Integer format
# @return [Google::Cloud::Spanner::RequestIdInterceptor]
def initialize process_id: nil
super
@version = 1
@process_id = self.class.send :get_process_id, process_id
@client_id = self.class.send :next_client_id
@channel_id = self.class.send :next_channel_id
@request_id_counter = 0
@request_mutex = Mutex.new
end

# Intercepts a request_response rpc call
#
# @param [String] method The RPC method name
# @param [Google::Protobuf::MessageExts] request The request to be sent to the RPC call
# @param [GRPC::ActiveCall::InterceptableView] call An interceptable view object for the call class
# @param [Hash] metadata All the metadata to be sent to the RPC call
# @return [void]
def request_response method:, request:, call:, metadata:, &block
# Unused. This is to avoid Rubocop's Lint/UnusedMethodArgument
_method = method
_request = request
_call = call
update_metadata_for_call metadata, &block
end

# Intercepts a client_streamer rpc call
#
# @param [String] method The RPC method name
# @param [Google::Protobuf::MessageExts] request The request to be sent to the RPC call
# @param [GRPC::ActiveCall::InterceptableView] call An interceptable view object for the call class
# @param [Hash] metadata All the metadata to be sent to the RPC call
# @return [void]
def client_streamer method:, request:, call:, metadata:, &block
# Unused. This is to avoid Rubocop's Lint/UnusedMethodArgument
_method = method
_request = request
_call = call
update_metadata_for_call metadata, &block
end

# Intercepts a server_streamer rpc call
#
# @param [String] method The RPC method name
# @param [Google::Protobuf::MessageExts] request The request to be sent to the RPC call
# @param [GRPC::ActiveCall::InterceptableView] call An interceptable view object for the call class
# @param [Hash] metadata All the metadata to be sent to the RPC call
# @return [void]
def server_streamer method:, request:, call:, metadata:, &block
# Unused. This is to avoid Rubocop's Lint/UnusedMethodArgument
_method = method
_request = request
_call = call
update_metadata_for_call metadata, &block
end

# Intercepts a bidi_streamer rpc call
#
# @param [String] method The RPC method name
# @param [Google::Protobuf::MessageExts] request The request to be sent to the RPC call
# @param [GRPC::ActiveCall::InterceptableView] call An interceptable view object for the call class
# @param [Hash] metadata The metadata to be sent to the RPC call
# @return [void]
def bidi_streamer method:, request:, call:, metadata:, &block
# Unused. This is to avoid Rubocop's Lint/UnusedMethodArgument
_method = method
_request = request
_call = call
update_metadata_for_call metadata, &block
end

private

# @private
# Inserts the Spanner request id header to the metadata for the RPC call
#
# @param [Hash] metadata The metadata to be sent to the RPC call
# @return [void]
def update_metadata_for_call metadata
request_id = nil
attempt = 1

if metadata.include? :"x-goog-spanner-request-id"
request_id, attempt = get_header_request_id_and_attempt metadata[:"x-goog-spanner-request-id"]
else
request_id = @request_mutex.synchronize { @request_id_counter += 1 }
end

formatted_request_id = format_request_id request_id, attempt
metadata[:"x-goog-spanner-request-id"] = formatted_request_id

yield
rescue StandardError => e
e.instance_variable_set :@spanner_header_id, formatted_request_id
raise e
end

# @private
# Creates the Spanner request id header in the correct format
#
# @param [String] request_id The request id of the Spanner request
# @param [String] attempt The attempt of the current request after retries
# @return [String]
def format_request_id request_id, attempt
"#{@version}.#{@process_id}.#{@client_id}.#{@channel_id}.#{request_id}.#{attempt}"
end

# @private
# Parses a request id header and returns the request id and the attempt
#
# @param [String] header A string representation of a Spanner request ID.
# @return [array]
def get_header_request_id_and_attempt header
_, _, _, _, request_id, attempt = header.split "."
[request_id, attempt.to_i + 1]
end
end
end
end
end
9 changes: 8 additions & 1 deletion google-cloud-spanner/lib/google/cloud/spanner/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class Service
attr_accessor :lib_name
attr_accessor :lib_version
attr_accessor :quota_project
attr_accessor :interceptors
attr_accessor :enable_leader_aware_routing

attr_reader :universe_domain
Expand All @@ -51,13 +52,15 @@ class Service
# @param timeout [::Numeric, nil] Optional. Timeout for Gapic client.
# @param lib_name [::String, nil] Optional. Library name for headers.
# @param lib_version [::String, nil] Optional. Library version for headers.
# @param interceptors [::Array<GRPC::ClientInterceptor>, nil] Optional.
# An array of interceptors that are run before calls are executed.
# @param enable_leader_aware_routing [::Boolean, nil] Optional. Whether Leader
# Aware Routing should be enabled.
# @param universe_domain [::String, nil] Optional. The domain of the universe to connect to.
# @private
def initialize project, credentials, quota_project: nil,
host: nil, timeout: nil, lib_name: nil, lib_version: nil,
enable_leader_aware_routing: nil, universe_domain: nil
interceptors: nil, enable_leader_aware_routing: nil, universe_domain: nil
@project = project
@credentials = credentials
@quota_project = quota_project || (credentials.quota_project_id if credentials.respond_to? :quota_project_id)
Expand All @@ -73,6 +76,7 @@ def initialize project, credentials, quota_project: nil,
@timeout = timeout
@lib_name = lib_name
@lib_version = lib_version
@interceptors = interceptors
@enable_leader_aware_routing = enable_leader_aware_routing
end

Expand Down Expand Up @@ -106,6 +110,7 @@ def service
config.lib_name = lib_name_with_prefix
config.lib_version = Google::Cloud::Spanner::VERSION
config.metadata = { "google-cloud-resource-prefix" => "projects/#{@project}" }
config.interceptors = @interceptors if @interceptors
end
end
attr_accessor :mocked_service
Expand All @@ -122,6 +127,7 @@ def instances
config.lib_name = lib_name_with_prefix
config.lib_version = Google::Cloud::Spanner::VERSION
config.metadata = { "google-cloud-resource-prefix" => "projects/#{@project}" }
config.interceptors = @interceptors if @interceptors
end
end
attr_accessor :mocked_instances
Expand All @@ -138,6 +144,7 @@ def databases
config.lib_name = lib_name_with_prefix
config.lib_version = Google::Cloud::Spanner::VERSION
config.metadata = { "google-cloud-resource-prefix" => "projects/#{@project}" }
config.interceptors = @interceptors if @interceptors
end
end
attr_accessor :mocked_databases
Expand Down
35 changes: 35 additions & 0 deletions google-cloud-spanner/lib/google/cloud/spanner/spanner_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


require "google/cloud/errors"

# This is a monkey patch for Google::Cloud::Error to add support for the request_id method
# to keep this Spanner exclusive method inside the Spanner code.
# This may be moved into the errors gem itself based on later assessment.
module Google
module Cloud
class Error
##
# The Spanner header ID if there was an error on the request.
#
# @return [String, nil]
#
def request_id
return nil unless cause.instance_variable_defined? :@spanner_header_id
cause.instance_variable_get :@spanner_header_id
end
end
end
end
Loading
Loading