Skip to content

Commit 7398117

Browse files
feat: Add Spanner request ID header
1 parent d82a010 commit 7398117

File tree

5 files changed

+492
-4
lines changed

5 files changed

+492
-4
lines changed
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
// Copyright 2025 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+
using Grpc.Core;
16+
using Google.Api.Gax.Grpc;
17+
using System;
18+
using System.Linq;
19+
using System.Threading.Tasks;
20+
using Xunit;
21+
using System.Collections.Generic;
22+
23+
namespace Google.Cloud.Spanner.V1.Tests;
24+
25+
public class RequestIdTests
26+
{
27+
private const string SampleDatabaseName = "projects/proj/instances/inst/databases/db";
28+
private const string SampleSessionName = "projects/proj/instances/inst/databases/db/sessions/sess";
29+
30+
[Fact]
31+
public void RequestId_Format()
32+
{
33+
var invoker = new RetryFakeCallInvoker();
34+
var client = new SpannerClientBuilder { CallInvoker = invoker }.Build();
35+
36+
client.CreateSession(new CreateSessionRequest { Database = SampleDatabaseName });
37+
38+
// The expected format is 6 parts that break down as such:
39+
// {VersionId}.{s_processId}.{ClientId}.{ChannelId}.{RequestSequence}.{AttemptNum}
40+
Metadata headerMetadata = invoker.CapturedMetadata[0];
41+
// We can't access RequestId.HeaderKey anymore, so use the string literal.
42+
var idString = headerMetadata.Get("x-goog-spanner-request-id").Value;
43+
44+
var parts = idString.Split('.');
45+
46+
Assert.Equal(6, parts.Length);
47+
var versionId = parts[0];
48+
var processId = parts[1];
49+
var clientId = parts[2];
50+
var channelId = parts[3];
51+
var requestSequence = parts[4];
52+
var attemptNum = parts[5];
53+
54+
Assert.Equal("1", versionId);
55+
Assert.True(ulong.TryParse(processId, out ulong processIdLong));
56+
// We want to make sure the random generation ran. The default value for ulong is 0
57+
// and with a very high probability this should not be 0 after generation.
58+
Assert.NotEqual(0UL, processIdLong);
59+
Assert.True(int.TryParse(clientId, out _));
60+
Assert.Equal("1", channelId);
61+
Assert.True(int.TryParse(requestSequence, out _));
62+
Assert.Equal("1", attemptNum);
63+
}
64+
65+
[Fact]
66+
public void RequestIdSource_ProcessId_OverwritingBehavior() =>
67+
// The process ID should only ever be set once per process.
68+
Assert.Throws<InvalidOperationException>(() =>
69+
{
70+
// Note in the case of test re-runs within the same process the
71+
// "first_override" may cause the exception to be thrown as it was
72+
// set prior.
73+
SpannerClientImpl.ProcessId = 1UL;
74+
SpannerClientImpl.ProcessId = 2UL;
75+
});
76+
77+
// Tests for header injection
78+
public static TheoryData<Action<SpannerClient>> SpannerClientActions { get; } = new TheoryData<Action<SpannerClient>>
79+
{
80+
client => client.ExecuteSql(new ExecuteSqlRequest { Session = SampleSessionName, Sql = "SELECT 1" }),
81+
client => client.GetSession(new GetSessionRequest { Name = SampleSessionName }),
82+
client => client.ListSessions(new ListSessionsRequest { Database = SampleDatabaseName }).AsRawResponses().First(),
83+
client => client.DeleteSession(new DeleteSessionRequest { Name = SampleSessionName }),
84+
client => client.ExecuteSql(new ExecuteSqlRequest { Session = SampleSessionName }),
85+
client => client.ExecuteBatchDml(new ExecuteBatchDmlRequest { Session = SampleSessionName }),
86+
client => client.Read(new ReadRequest { Session = SampleSessionName }),
87+
client => client.BeginTransaction(new BeginTransactionRequest { Session = SampleSessionName }),
88+
client => client.Commit(new CommitRequest { Session = SampleSessionName }),
89+
client => client.Rollback(new RollbackRequest { Session = SampleSessionName }),
90+
client => client.PartitionQuery(new PartitionQueryRequest { Session = SampleSessionName }),
91+
client => client.PartitionRead(new PartitionReadRequest { Session = SampleSessionName })
92+
};
93+
94+
[Theory]
95+
[MemberData(nameof(SpannerClientActions))]
96+
public void SetsHeaderOnRpcCalls(Action<SpannerClient> action)
97+
{
98+
var invoker = new RetryFakeCallInvoker();
99+
var client = new SpannerClientBuilder { CallInvoker = invoker }.Build();
100+
action(client);
101+
Metadata.Entry entry = Assert.Single(invoker.CapturedMetadata[0], e => e.Key == "x-goog-spanner-request-id");
102+
Assert.NotNull(entry.Value);
103+
}
104+
105+
[Fact]
106+
public void IncrementsRequestIdOnRetry()
107+
{
108+
var invoker = new RetryFakeCallInvoker(failCount: 1);
109+
var settings = new SpannerSettings
110+
{
111+
// Configure the CreateSession call to retry on Unavailable errors.
112+
CallSettings = CallSettings.FromRetry(RetrySettings.FromExponentialBackoff(
113+
maxAttempts: 3,
114+
initialBackoff: TimeSpan.FromMilliseconds(1),
115+
maxBackoff: TimeSpan.FromMilliseconds(1),
116+
backoffMultiplier: 1.0,
117+
retryFilter: RetrySettings.FilterForStatusCodes(StatusCode.Unavailable)))
118+
};
119+
var client = new SpannerClientBuilder { CallInvoker = invoker, Settings = settings }.Build();
120+
121+
client.CreateSession(new CreateSessionRequest { Database = SampleDatabaseName });
122+
client.CreateSession(new CreateSessionRequest { Database = SampleDatabaseName });
123+
client.CreateSession(new CreateSessionRequest { Database = SampleDatabaseName });
124+
125+
// Assert that the invoker was called four times for the three client calls.
126+
// The first call should have failed the first time and succeeded on retry.
127+
Assert.Equal(4, invoker.CapturedMetadata.Count);
128+
129+
var requestIds = invoker.CapturedMetadata
130+
.Select(m => m.Single(e => e.Key == "x-goog-spanner-request-id").Value)
131+
.Select(id => new SpannerRequestIdParts(id))
132+
.ToList();
133+
134+
Assert.Equal((1, 1), (requestIds[0].RequestSequence, requestIds[0].AttemptNum));
135+
Assert.Equal((1, 2), (requestIds[1].RequestSequence, requestIds[1].AttemptNum));
136+
Assert.Equal((2, 1), (requestIds[2].RequestSequence, requestIds[2].AttemptNum));
137+
Assert.Equal((3, 1), (requestIds[3].RequestSequence, requestIds[3].AttemptNum));
138+
}
139+
140+
/// <summary>
141+
/// A fake call invoker that fails the first time it's called with an "Unavailable" status,
142+
/// and succeeds on subsequent calls. It captures the metadata for every call.
143+
/// </summary>
144+
private class RetryFakeCallInvoker : CallInvoker
145+
{
146+
private int _callCount = 0;
147+
private readonly int _failCount;
148+
public List<Metadata> CapturedMetadata { get; } = new List<Metadata>();
149+
150+
public RetryFakeCallInvoker(int failCount = 0) => _failCount = failCount;
151+
152+
public override TResponse BlockingUnaryCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request)
153+
{
154+
CapturedMetadata.Add(options.Headers);
155+
_callCount++;
156+
157+
if (_callCount <= _failCount)
158+
{
159+
// Fail the first time with a retryable error.
160+
throw new RpcException(new Status(StatusCode.Unavailable, "Transient error"));
161+
}
162+
163+
// Succeed on the second attempt.
164+
return (TResponse)Activator.CreateInstance(typeof(TResponse));
165+
}
166+
167+
public override AsyncClientStreamingCall<TRequest, TResponse> AsyncClientStreamingCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options) =>
168+
throw new NotImplementedException();
169+
170+
public override AsyncDuplexStreamingCall<TRequest, TResponse> AsyncDuplexStreamingCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options) =>
171+
throw new NotImplementedException();
172+
173+
public override AsyncServerStreamingCall<TResponse> AsyncServerStreamingCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request) =>
174+
throw new NotImplementedException();
175+
176+
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request)
177+
{
178+
CapturedMetadata.Add(options.Headers);
179+
_callCount++;
180+
181+
if (_callCount <= _failCount)
182+
{
183+
// Fail the first time with a retryable error.
184+
throw new RpcException(new Status(StatusCode.Unavailable, "Transient error"));
185+
}
186+
187+
// Succeed on the second attempt.
188+
var response = (TResponse)Activator.CreateInstance(typeof(TResponse));
189+
return new AsyncUnaryCall<TResponse>(Task.FromResult(response), Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => new Metadata(), () => { });
190+
}
191+
}
192+
193+
private struct SpannerRequestIdParts
194+
{
195+
public int VersionId { get; }
196+
public ulong ProcessId { get; }
197+
public int ClientId { get; }
198+
public int ChannelId { get; }
199+
public int RequestSequence { get; }
200+
public int AttemptNum { get; }
201+
202+
public SpannerRequestIdParts(string requestId)
203+
{
204+
var parts = requestId.Split('.');
205+
Assert.Equal(6, parts.Length);
206+
207+
VersionId = int.Parse(parts[0]);
208+
ProcessId = ulong.Parse(parts[1]);
209+
ClientId = int.Parse(parts[2]);
210+
ChannelId = int.Parse(parts[3]);
211+
RequestSequence = int.Parse(parts[4]);
212+
AttemptNum = int.Parse(parts[5]);
213+
}
214+
}
215+
}

apis/Google.Cloud.Spanner.V1/Google.Cloud.Spanner.V1/SpannerClientBuilderPartial.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ namespace Google.Cloud.Spanner.V1
2525
{
2626
public partial class SpannerClientBuilder
2727
{
28+
/// <summary>
29+
/// The process ID, assigned to each outgoing RPC to identify the request source. This can be overridden,
30+
/// but only before the process has made its first Spanner request.
31+
/// </summary>
32+
public static ulong ProcessId
33+
{
34+
set => SpannerClientImpl.ProcessId = value;
35+
}
36+
2837
/// <summary>
2938
/// The Grpc.Gcp method configurations for pool options.
3039
/// </summary>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright 2025 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+
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
using Grpc.Core;
17+
18+
namespace Google.Cloud.Spanner.V1;
19+
20+
public partial class SpannerClientImpl
21+
{
22+
/// <summary>
23+
/// The header key used for Spanner request IDs.
24+
/// </summary>
25+
internal const string RequestIdHeaderKey = "x-goog-spanner-request-id";
26+
27+
/// <summary>
28+
/// Represents a structured request ID for Spanner RPCs, formatted as:
29+
/// {version}.{process}.{client}.{channel}.{request}.{attempt}
30+
/// </summary>
31+
private sealed class RequestId
32+
{
33+
/// <summary>
34+
/// The version number of the request ID format.
35+
/// </summary>
36+
private const int FormatVersion = 1;
37+
38+
/// <summary>
39+
/// A unique ID for the user application process. This can be overridden, but only before it has been accessed.
40+
/// </summary>
41+
private readonly ulong _processId;
42+
43+
/// <summary>
44+
/// A unique ID for the gRPC channel being used. This is hardcoded to 1 for now.
45+
/// See: b/459445539
46+
/// </summary>
47+
private readonly int _channelId = 1;
48+
49+
/// <summary>
50+
/// A unique ID for the <see cref="SpannerClient"/> instance that generated the request.
51+
/// </summary>
52+
private readonly int _clientId;
53+
54+
/// <summary>
55+
/// A unique ID for the logical request made using the current <see cref="SpannerClient"/>.
56+
/// </summary>
57+
private readonly int _requestId;
58+
59+
/// <summary>
60+
/// The attempt count of the request, incremented before each RPC attempt.
61+
/// </summary>
62+
private int _attemptCount;
63+
64+
/// <summary>
65+
/// Initializes a new instance of the <see cref="RequestId"/> class
66+
/// with a specified client and logical request identifier.
67+
/// </summary>
68+
/// <param name="clientId">The ID for the <see cref="SpannerClient"/> associated with the request.</param>
69+
/// <param name="processId">The ID for the user application process.</param>
70+
/// <param name="requestId">The ID of the logical request within the client.</param>
71+
internal RequestId(ulong processId, int clientId, int requestId)
72+
{
73+
_processId = processId;
74+
_clientId = clientId;
75+
_requestId = requestId;
76+
}
77+
78+
/// <summary>
79+
/// Increments the request's attempt number and then returns the request ID.
80+
/// </summary>
81+
private RequestId IncrementAttempt()
82+
{
83+
// Retry attempts are expected to be sequential so we can safely
84+
// increment without first acquiring a lock.
85+
_attemptCount++;
86+
return this;
87+
}
88+
89+
/// <summary>
90+
/// Add a request ID header. The header mutation increments the attempt each time the header is populated which
91+
/// happens right before a call. This allows us to increment the attempt num and assign a unique request id in the
92+
/// case of retries.
93+
/// </summary>
94+
internal void AddHeader(Metadata metadata) => metadata.Add(RequestIdHeaderKey, IncrementAttempt().ToString());
95+
96+
/// <summary>
97+
/// Returns the string representation of the Spanner request ID.
98+
/// </summary>
99+
public override string ToString() => $"{FormatVersion}.{_processId}.{_clientId}.{_channelId}.{_requestId}.{_attemptCount}";
100+
101+
}
102+
}

0 commit comments

Comments
 (0)