Skip to content

Commit 6f2fcde

Browse files
feat: Support request id header feature (#215)
1 parent 28434ef commit 6f2fcde

7 files changed

Lines changed: 461 additions & 3 deletions

File tree

google-cloud-spanner/Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ gem "minitest", "~> 5.25"
1616
gem "minitest-autotest", "~> 1.0"
1717
gem "minitest-focus", "~> 1.4"
1818
gem "minitest-rg", "~> 5.3"
19+
gem "mutex_m", "~> 0.3.0"
1920
gem "pry", group: :development, require: false
2021
gem "rake"
2122
gem "redcarpet", "~> 3.0"

google-cloud-spanner/lib/google/cloud/spanner.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
require "google-cloud-spanner"
1717
require "google/cloud/spanner/project"
18+
require "google/cloud/spanner/spanner_error"
19+
require "google/cloud/spanner/request_id_interceptor"
1820
require "google/cloud/config"
1921
require "google/cloud/env"
2022

@@ -96,7 +98,7 @@ module Spanner
9698
def self.new project_id: nil, credentials: nil, scope: nil, timeout: nil,
9799
endpoint: nil, project: nil, keyfile: nil,
98100
emulator_host: nil, lib_name: nil, lib_version: nil,
99-
enable_leader_aware_routing: true, universe_domain: nil
101+
enable_leader_aware_routing: true, universe_domain: nil, process_id: nil
100102
project_id ||= project || default_project_id
101103
scope ||= configure.scope
102104
timeout ||= configure.timeout
@@ -105,6 +107,7 @@ def self.new project_id: nil, credentials: nil, scope: nil, timeout: nil,
105107
credentials ||= keyfile
106108
lib_name ||= configure.lib_name
107109
lib_version ||= configure.lib_version
110+
interceptors = [RequestIdInterceptor.new(process_id: process_id)]
108111
universe_domain ||= configure.universe_domain
109112

110113
if emulator_host
@@ -127,7 +130,8 @@ def self.new project_id: nil, credentials: nil, scope: nil, timeout: nil,
127130
Spanner::Service.new(
128131
project_id, credentials, quota_project: configure.quota_project,
129132
host: endpoint, timeout: timeout, lib_name: lib_name,
130-
lib_version: lib_version, universe_domain: universe_domain,
133+
lib_version: lib_version, interceptors: interceptors,
134+
universe_domain: universe_domain,
131135
enable_leader_aware_routing: enable_leader_aware_routing
132136
),
133137
query_options: configure.query_options

google-cloud-spanner/lib/google/cloud/spanner/errors.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515

1616
require "google/cloud/errors"
17+
require "google/cloud/spanner/spanner_error"
1718

1819
module Google
1920
module Cloud
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
require "grpc"
17+
require "securerandom"
18+
require "mutex_m"
19+
require "google/cloud/spanner/errors"
20+
21+
module Google
22+
module Cloud
23+
module Spanner
24+
##
25+
# RequestIdInterceptor is a GRPC interceptor class that captures all the rpc calls
26+
# made by the GRPC layer inserting a new Header with a specific ID for debugging purposes.
27+
#
28+
class RequestIdInterceptor < GRPC::ClientInterceptor
29+
@client_id_counter = 0
30+
@client_mutex = Mutex.new
31+
@channel_id_counter = 0
32+
@channel_mutex = Mutex.new
33+
@request_id_counter = 0
34+
@request_id_mutex = Mutex.new
35+
@process_id = nil
36+
@process_id_mutex = Mutex.new
37+
38+
# @private
39+
# Gets the next client ID and increments it.
40+
#
41+
# @return [Integer]
42+
private_class_method def self.next_client_id
43+
@client_mutex.synchronize do
44+
@client_id_counter += 1
45+
end
46+
end
47+
48+
# @private
49+
# Gets the next channel ID and increments it.
50+
#
51+
# @return [Integer]
52+
private_class_method def self.next_channel_id
53+
@channel_mutex.synchronize do
54+
@channel_id_counter += 1
55+
end
56+
end
57+
58+
# @private
59+
# Returns a process ID for the context of the request id header.
60+
# A process ID is a Hex encoded 64 bit value
61+
#
62+
# @param [String, int] process_id A 64 bit value in Hex or Integer format
63+
# @return [String]
64+
private_class_method def self.get_process_id process_id = nil
65+
@process_id_mutex.synchronize do
66+
if process_id.nil? || !@process_id.nil?
67+
return @process_id ||= (SecureRandom.hex 8)
68+
end
69+
70+
case process_id
71+
when Integer
72+
if process_id >= 0 && process_id.bit_length <= 64
73+
return process_id.to_s(16).rjust(16, "0")
74+
end
75+
when String
76+
if process_id =~ /\A[0-9a-fA-F]{16}\z/
77+
return process_id
78+
end
79+
end
80+
81+
raise ArgumentError, "process_id must be a 64-bit integer or 16-character hex string"
82+
end
83+
end
84+
85+
# Initializes a request_id_interceptor instance.
86+
#
87+
# @param [String, int] process_id A 64 bit value in Hex or Integer format
88+
# @return [Google::Cloud::Spanner::RequestIdInterceptor]
89+
def initialize process_id: nil
90+
super
91+
@version = 1
92+
@process_id = self.class.send :get_process_id, process_id
93+
@client_id = self.class.send :next_client_id
94+
@channel_id = self.class.send :next_channel_id
95+
@request_id_counter = 0
96+
@request_mutex = Mutex.new
97+
end
98+
99+
# Intercepts a request_response rpc call
100+
#
101+
# @param [String] method The RPC method name
102+
# @param [Google::Protobuf::MessageExts] request The request to be sent to the RPC call
103+
# @param [GRPC::ActiveCall::InterceptableView] call An interceptable view object for the call class
104+
# @param [Hash] metadata All the metadata to be sent to the RPC call
105+
# @return [void]
106+
def request_response method:, request:, call:, metadata:, &block
107+
# Unused. This is to avoid Rubocop's Lint/UnusedMethodArgument
108+
_method = method
109+
_request = request
110+
_call = call
111+
update_metadata_for_call metadata, &block
112+
end
113+
114+
# Intercepts a client_streamer rpc call
115+
#
116+
# @param [String] method The RPC method name
117+
# @param [Google::Protobuf::MessageExts] request The request to be sent to the RPC call
118+
# @param [GRPC::ActiveCall::InterceptableView] call An interceptable view object for the call class
119+
# @param [Hash] metadata All the metadata to be sent to the RPC call
120+
# @return [void]
121+
def client_streamer method:, request:, call:, metadata:, &block
122+
# Unused. This is to avoid Rubocop's Lint/UnusedMethodArgument
123+
_method = method
124+
_request = request
125+
_call = call
126+
update_metadata_for_call metadata, &block
127+
end
128+
129+
# Intercepts a server_streamer rpc call
130+
#
131+
# @param [String] method The RPC method name
132+
# @param [Google::Protobuf::MessageExts] request The request to be sent to the RPC call
133+
# @param [GRPC::ActiveCall::InterceptableView] call An interceptable view object for the call class
134+
# @param [Hash] metadata All the metadata to be sent to the RPC call
135+
# @return [void]
136+
def server_streamer method:, request:, call:, metadata:, &block
137+
# Unused. This is to avoid Rubocop's Lint/UnusedMethodArgument
138+
_method = method
139+
_request = request
140+
_call = call
141+
update_metadata_for_call metadata, &block
142+
end
143+
144+
# Intercepts a bidi_streamer rpc call
145+
#
146+
# @param [String] method The RPC method name
147+
# @param [Google::Protobuf::MessageExts] request The request to be sent to the RPC call
148+
# @param [GRPC::ActiveCall::InterceptableView] call An interceptable view object for the call class
149+
# @param [Hash] metadata The metadata to be sent to the RPC call
150+
# @return [void]
151+
def bidi_streamer method:, request:, call:, metadata:, &block
152+
# Unused. This is to avoid Rubocop's Lint/UnusedMethodArgument
153+
_method = method
154+
_request = request
155+
_call = call
156+
update_metadata_for_call metadata, &block
157+
end
158+
159+
private
160+
161+
# @private
162+
# Inserts the Spanner request id header to the metadata for the RPC call
163+
#
164+
# @param [Hash] metadata The metadata to be sent to the RPC call
165+
# @return [void]
166+
def update_metadata_for_call metadata
167+
request_id = nil
168+
attempt = 1
169+
170+
if metadata.include? :"x-goog-spanner-request-id"
171+
request_id, attempt = get_header_request_id_and_attempt metadata[:"x-goog-spanner-request-id"]
172+
else
173+
request_id = @request_mutex.synchronize { @request_id_counter += 1 }
174+
end
175+
176+
formatted_request_id = format_request_id request_id, attempt
177+
metadata[:"x-goog-spanner-request-id"] = formatted_request_id
178+
179+
yield
180+
rescue StandardError => e
181+
e.instance_variable_set :@spanner_header_id, formatted_request_id
182+
raise e
183+
end
184+
185+
# @private
186+
# Creates the Spanner request id header in the correct format
187+
#
188+
# @param [String] request_id The request id of the Spanner request
189+
# @param [String] attempt The attempt of the current request after retries
190+
# @return [String]
191+
def format_request_id request_id, attempt
192+
"#{@version}.#{@process_id}.#{@client_id}.#{@channel_id}.#{request_id}.#{attempt}"
193+
end
194+
195+
# @private
196+
# Parses a request id header and returns the request id and the attempt
197+
#
198+
# @param [String] header A string representation of a Spanner request ID.
199+
# @return [array]
200+
def get_header_request_id_and_attempt header
201+
_, _, _, _, request_id, attempt = header.split "."
202+
[request_id, attempt.to_i + 1]
203+
end
204+
end
205+
end
206+
end
207+
end

google-cloud-spanner/lib/google/cloud/spanner/service.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class Service
3636
attr_accessor :lib_name
3737
attr_accessor :lib_version
3838
attr_accessor :quota_project
39+
attr_accessor :interceptors
3940
attr_accessor :enable_leader_aware_routing
4041

4142
attr_reader :universe_domain
@@ -51,13 +52,15 @@ class Service
5152
# @param timeout [::Numeric, nil] Optional. Timeout for Gapic client.
5253
# @param lib_name [::String, nil] Optional. Library name for headers.
5354
# @param lib_version [::String, nil] Optional. Library version for headers.
55+
# @param interceptors [::Array<GRPC::ClientInterceptor>, nil] Optional.
56+
# An array of interceptors that are run before calls are executed.
5457
# @param enable_leader_aware_routing [::Boolean, nil] Optional. Whether Leader
5558
# Aware Routing should be enabled.
5659
# @param universe_domain [::String, nil] Optional. The domain of the universe to connect to.
5760
# @private
5861
def initialize project, credentials, quota_project: nil,
5962
host: nil, timeout: nil, lib_name: nil, lib_version: nil,
60-
enable_leader_aware_routing: nil, universe_domain: nil
63+
interceptors: nil, enable_leader_aware_routing: nil, universe_domain: nil
6164
@project = project
6265
@credentials = credentials
6366
@quota_project = quota_project || (credentials.quota_project_id if credentials.respond_to? :quota_project_id)
@@ -73,6 +76,7 @@ def initialize project, credentials, quota_project: nil,
7376
@timeout = timeout
7477
@lib_name = lib_name
7578
@lib_version = lib_version
79+
@interceptors = interceptors
7680
@enable_leader_aware_routing = enable_leader_aware_routing
7781
end
7882

@@ -106,6 +110,7 @@ def service
106110
config.lib_name = lib_name_with_prefix
107111
config.lib_version = Google::Cloud::Spanner::VERSION
108112
config.metadata = { "google-cloud-resource-prefix" => "projects/#{@project}" }
113+
config.interceptors = @interceptors if @interceptors
109114
end
110115
end
111116
attr_accessor :mocked_service
@@ -122,6 +127,7 @@ def instances
122127
config.lib_name = lib_name_with_prefix
123128
config.lib_version = Google::Cloud::Spanner::VERSION
124129
config.metadata = { "google-cloud-resource-prefix" => "projects/#{@project}" }
130+
config.interceptors = @interceptors if @interceptors
125131
end
126132
end
127133
attr_accessor :mocked_instances
@@ -138,6 +144,7 @@ def databases
138144
config.lib_name = lib_name_with_prefix
139145
config.lib_version = Google::Cloud::Spanner::VERSION
140146
config.metadata = { "google-cloud-resource-prefix" => "projects/#{@project}" }
147+
config.interceptors = @interceptors if @interceptors
141148
end
142149
end
143150
attr_accessor :mocked_databases
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
require "google/cloud/errors"
17+
18+
# This is a monkey patch for Google::Cloud::Error to add support for the request_id method
19+
# to keep this Spanner exclusive method inside the Spanner code.
20+
# This may be moved into the errors gem itself based on later assessment.
21+
module Google
22+
module Cloud
23+
class Error
24+
##
25+
# The Spanner header ID if there was an error on the request.
26+
#
27+
# @return [String, nil]
28+
#
29+
def request_id
30+
return nil unless cause.instance_variable_defined? :@spanner_header_id
31+
cause.instance_variable_get :@spanner_header_id
32+
end
33+
end
34+
end
35+
end

0 commit comments

Comments
 (0)