Skip to content

Commit c06ffb0

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

File tree

5 files changed

+486
-4
lines changed

5 files changed

+486
-4
lines changed
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
// Copyright 2024 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 expectedProcessId = "processId";
34+
var expectedClientId = 123;
35+
var expectedRequestSequence = 456;
36+
var requestId = new RequestId(expectedProcessId, expectedClientId, expectedRequestSequence);
37+
38+
// The expected format is 6 parts that break down as such:
39+
// {VersionId}.{s_processId}.{ClientId}.{ChannelId}.{RequestSequence}.{AttemptNum}
40+
var idString = requestId.IncrementAttempt().ToString();
41+
var parts = idString.Split('.');
42+
43+
Assert.Equal(6, parts.Length);
44+
var versionId = parts[0];
45+
var processId = parts[1];
46+
var clientId = parts[2];
47+
var channelId = parts[3];
48+
var requestSequence = parts[4];
49+
var attemptNum = parts[5];
50+
51+
Assert.Equal("1", versionId);
52+
Assert.Equal(expectedClientId.ToString(), clientId);
53+
Assert.Equal("1", channelId);
54+
Assert.Equal(expectedRequestSequence.ToString(), requestSequence);
55+
Assert.Equal("1", attemptNum);
56+
Assert.Equal(expectedProcessId, processId);
57+
}
58+
59+
[Fact]
60+
public void RequestId_IncrementAttemptNum()
61+
{
62+
var requestId = new RequestId("processId", 1, 1);
63+
for (int expectedAttemptNum = 1; expectedAttemptNum < 5; expectedAttemptNum++)
64+
{
65+
var parts = requestId.IncrementAttempt().ToString().Split('.');
66+
Assert.Equal(6, parts.Length);
67+
var actualAttemptNum = parts[5];
68+
Assert.Equal($"{expectedAttemptNum}", actualAttemptNum);
69+
}
70+
}
71+
72+
[Fact]
73+
public void RequestIdSource_ProcessId_OverwritingBehavior()
74+
{
75+
// Capture the initial ProcessId. This could be the default generated ID,
76+
// or a value set by another test if XUnit's test execution order leads to state leakage.
77+
var initialProcessId = RequestIdSource.ProcessId;
78+
79+
// Try to set the ProcessId to "first_override".
80+
// This will only succeed if ProcessId has not been accessed/initialized yet.
81+
RequestIdSource.ProcessId = "first_override";
82+
83+
// After attempting to set, retrieve the current ProcessId.
84+
var currentProcessId = RequestIdSource.ProcessId;
85+
86+
// If ProcessId was initially the default and we successfully set it to "first_override",
87+
// then currentProcessId should be "first_override".
88+
// Otherwise, it means ProcessId was already created (either by default or another test),
89+
// and our attempt to set it was ignored. In this case, currentProcessId should equal initialProcessId.
90+
string expectedProcessIdAfterFirstAttempt = initialProcessId == currentProcessId ? initialProcessId : "first_override";
91+
Assert.Equal(expectedProcessIdAfterFirstAttempt, currentProcessId);
92+
93+
// Now, attempt to set it a second time. This should always be ignored if already initialized.
94+
RequestIdSource.ProcessId = "second_override";
95+
96+
// Verify that the ProcessId has not changed from its state after the first attempt.
97+
Assert.Equal(expectedProcessIdAfterFirstAttempt, RequestIdSource.ProcessId);
98+
}
99+
100+
// Tests for header injection
101+
public static TheoryData<Action<SpannerClient>> SpannerClientActions { get; } = new TheoryData<Action<SpannerClient>>
102+
{
103+
client => client.CreateSession(new CreateSessionRequest { Database = SampleDatabaseName }),
104+
client => client.BatchCreateSessions(new BatchCreateSessionsRequest { Database = SampleDatabaseName }),
105+
client => client.GetSession(new GetSessionRequest { Name = SampleSessionName }),
106+
client => client.ListSessions(new ListSessionsRequest { Database = SampleDatabaseName }).AsRawResponses().First(),
107+
client => client.DeleteSession(new DeleteSessionRequest { Name = SampleSessionName }),
108+
client => client.ExecuteSql(new ExecuteSqlRequest { Session = SampleSessionName }),
109+
client => client.ExecuteBatchDml(new ExecuteBatchDmlRequest { Session = SampleSessionName }),
110+
client => client.Read(new ReadRequest { Session = SampleSessionName }),
111+
client => client.BeginTransaction(new BeginTransactionRequest { Session = SampleSessionName }),
112+
client => client.Commit(new CommitRequest { Session = SampleSessionName }),
113+
client => client.Rollback(new RollbackRequest { Session = SampleSessionName }),
114+
client => client.PartitionQuery(new PartitionQueryRequest { Session = SampleSessionName }),
115+
client => client.PartitionRead(new PartitionReadRequest { Session = SampleSessionName })
116+
};
117+
118+
[Theory]
119+
[MemberData(nameof(SpannerClientActions))]
120+
public void SetsHeaderOnRpcCalls(Action<SpannerClient> action)
121+
{
122+
var invoker = new RetryFakeCallInvoker();
123+
var client = new SpannerClientBuilder { CallInvoker = invoker }.Build();
124+
action(client);
125+
Metadata.Entry entry = Assert.Single(invoker.CapturedMetadata[0], e => e.Key == RequestId.HeaderKey);
126+
Assert.NotNull(entry.Value);
127+
}
128+
129+
[Fact]
130+
public void IncrementsRequestIdOnRetry()
131+
{
132+
var invoker = new RetryFakeCallInvoker(failCount: 1);
133+
var settings = new SpannerSettings
134+
{
135+
// Configure the CreateSession call to retry on Unavailable errors.
136+
CallSettings = CallSettings.FromRetry(RetrySettings.FromExponentialBackoff(
137+
maxAttempts: 3,
138+
initialBackoff: TimeSpan.FromMilliseconds(1),
139+
maxBackoff: TimeSpan.FromMilliseconds(1),
140+
backoffMultiplier: 1.0,
141+
retryFilter: RetrySettings.FilterForStatusCodes(StatusCode.Unavailable)))
142+
};
143+
var client = new SpannerClientBuilder { CallInvoker = invoker, Settings = settings }.Build();
144+
145+
client.CreateSession(new CreateSessionRequest { Database = SampleDatabaseName });
146+
client.CreateSession(new CreateSessionRequest { Database = SampleDatabaseName });
147+
client.CreateSession(new CreateSessionRequest { Database = SampleDatabaseName });
148+
149+
// Assert that the invoker was called four times for the three client calls.
150+
// The first call should have failed the first time and succeeded on retry.
151+
Assert.Equal(4, invoker.CapturedMetadata.Count);
152+
153+
var requestIds = invoker.CapturedMetadata
154+
.Select(m => m.Single(e => e.Key == RequestId.HeaderKey).Value)
155+
.Select(id => new SpannerRequestIdParts(id))
156+
.ToList();
157+
158+
Assert.Equal((1, 1), (requestIds[0].RequestSequence, requestIds[0].AttemptNum));
159+
Assert.Equal((1, 2), (requestIds[1].RequestSequence, requestIds[1].AttemptNum));
160+
Assert.Equal((2, 1), (requestIds[2].RequestSequence, requestIds[2].AttemptNum));
161+
Assert.Equal((3, 1), (requestIds[3].RequestSequence, requestIds[3].AttemptNum));
162+
}
163+
164+
/// <summary>
165+
/// A fake call invoker that fails the first time it's called with an "Unavailable" status,
166+
/// and succeeds on subsequent calls. It captures the metadata for every call.
167+
/// </summary>
168+
private class RetryFakeCallInvoker : CallInvoker
169+
{
170+
private int _callCount = 0;
171+
private readonly int _failCount;
172+
public List<Metadata> CapturedMetadata { get; } = new List<Metadata>();
173+
174+
public RetryFakeCallInvoker(int failCount = 0) => _failCount = failCount;
175+
176+
public override TResponse BlockingUnaryCall<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+
return (TResponse)Activator.CreateInstance(typeof(TResponse));
189+
}
190+
191+
public override AsyncClientStreamingCall<TRequest, TResponse> AsyncClientStreamingCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options) =>
192+
throw new NotImplementedException();
193+
194+
public override AsyncDuplexStreamingCall<TRequest, TResponse> AsyncDuplexStreamingCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options) =>
195+
throw new NotImplementedException();
196+
197+
public override AsyncServerStreamingCall<TResponse> AsyncServerStreamingCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request) =>
198+
throw new NotImplementedException();
199+
200+
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(Method<TRequest, TResponse> method, string host, CallOptions options, TRequest request)
201+
{
202+
CapturedMetadata.Add(options.Headers);
203+
_callCount++;
204+
205+
if (_callCount <= _failCount)
206+
{
207+
// Fail the first time with a retryable error.
208+
throw new RpcException(new Status(StatusCode.Unavailable, "Transient error"));
209+
}
210+
211+
// Succeed on the second attempt.
212+
var response = (TResponse)Activator.CreateInstance(typeof(TResponse));
213+
return new AsyncUnaryCall<TResponse>(Task.FromResult(response), Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => new Metadata(), () => { });
214+
}
215+
}
216+
217+
private struct SpannerRequestIdParts
218+
{
219+
public int VersionId { get; }
220+
public ulong ProcessId { get; }
221+
public int ClientId { get; }
222+
public int ChannelId { get; }
223+
public int RequestSequence { get; }
224+
public int AttemptNum { get; }
225+
226+
public SpannerRequestIdParts(string requestId)
227+
{
228+
var parts = requestId.Split('.');
229+
Assert.Equal(6, parts.Length);
230+
231+
VersionId = int.Parse(parts[0]);
232+
ProcessId = ulong.Parse(parts[1]);
233+
ClientId = int.Parse(parts[2]);
234+
ChannelId = int.Parse(parts[3]);
235+
RequestSequence = int.Parse(parts[4]);
236+
AttemptNum = int.Parse(parts[5]);
237+
}
238+
}
239+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
namespace Google.Cloud.Spanner.V1;
17+
18+
/// <summary>
19+
/// Represents a structured request ID for Spanner RPCs, formatted as:
20+
/// {version}.{process}.{client}.{channel}.{request}.{attempt}
21+
/// </summary>
22+
internal sealed class RequestId
23+
{
24+
/// <summary>
25+
/// The header key used for Spanner request IDs.
26+
/// </summary>
27+
internal const string HeaderKey = "x-goog-spanner-request-id";
28+
29+
30+
/// <summary>
31+
/// The version number of the request ID format.
32+
/// </summary>
33+
private const int FormatVersion = 1;
34+
35+
/// <summary>
36+
/// A unique ID for the user application process. This can be overridden, but only before it has been accessed.
37+
/// </summary>
38+
private readonly string _processId;
39+
40+
/// <summary>
41+
/// A unique ID for the gRPC channel being used. This is hardcoded to 1 for now.
42+
/// See: b/459445539
43+
/// </summary>
44+
private readonly int _channelId = 1;
45+
46+
/// <summary>
47+
/// A unique ID for the <see cref="SpannerClient"/> instance that generated the request.
48+
/// </summary>
49+
private readonly int _clientId;
50+
51+
/// <summary>
52+
/// A unique ID for the logical request made using the current <see cref="SpannerClient"/>.
53+
/// </summary>
54+
private readonly int _requestId;
55+
56+
/// <summary>
57+
/// A unique ID for each attempt of the request, incremented before each RPC attempt.
58+
/// </summary>
59+
private int _attemptId = 0;
60+
61+
/// <summary>
62+
/// Initializes a new instance of the <see cref="RequestId"/> class
63+
/// with a specified client and logical request identifier.
64+
/// </summary>
65+
/// <param name="clientId">The ID for the <see cref="SpannerClient"/> associated with the request.</param>
66+
/// <param name="processId">The ID for the user application process.</param>
67+
/// <param name="requestId">The ID of the logical request within the client.</param>
68+
internal RequestId(string processId, int clientId, int requestId)
69+
{
70+
_processId = processId;
71+
_clientId = clientId;
72+
_requestId = requestId;
73+
}
74+
75+
/// <summary>
76+
/// Increments the request's attempt number and then returns the request ID.
77+
/// </summary>
78+
internal RequestId IncrementAttempt()
79+
{
80+
// Retry attempts are expected to be sequential so we can safely
81+
// increment without first acquiring a lock.
82+
_attemptId++;
83+
return this;
84+
}
85+
86+
/// <summary>
87+
/// Returns the string representation of the Spanner request ID.
88+
/// </summary>
89+
public override string ToString() => $"{FormatVersion}.{_processId}.{_clientId}.{_channelId}.{_requestId}.{_attemptId}";
90+
91+
}

0 commit comments

Comments
 (0)