Skip to content

Commit 824d99b

Browse files
committed
test(spanner): add a mock server test using key-aware routing
Adds a mock server test that uses key-aware routing. This test is not testing much interesting, but serves as a base for future tests that can run against a mock server to test for example replica selection.
1 parent 48127bf commit 824d99b

File tree

2 files changed

+340
-3
lines changed

2 files changed

+340
-3
lines changed

java-spanner/google-cloud-spanner/src/test/java/com/google/cloud/spanner/MockSpannerServiceImpl.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,9 @@ public PartialResultSet next() {
219219
PartialResultSet.Builder builder = PartialResultSet.newBuilder();
220220
if (first) {
221221
builder.setMetadata(resultSet.getMetadata());
222+
if (resultSet.hasCacheUpdate()) {
223+
builder.setCacheUpdate(resultSet.getCacheUpdate());
224+
}
222225
first = false;
223226
}
224227
int recordCount = 0;
@@ -380,9 +383,6 @@ private static boolean isValidKeySet(KeySet keySet) {
380383
int keys = 0;
381384
for (Key key : keySet.getKeys()) {
382385
keys++;
383-
if (key.size() != 0) {
384-
return false;
385-
}
386386
}
387387
return keys == 1;
388388
}
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
/*
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
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+
17+
package com.google.cloud.spanner.spi.v1;
18+
19+
import static org.junit.Assert.assertFalse;
20+
import static org.junit.Assert.assertNotNull;
21+
import static org.junit.Assert.assertTrue;
22+
23+
import com.google.cloud.spanner.DatabaseClient;
24+
import com.google.cloud.spanner.DatabaseId;
25+
import com.google.cloud.spanner.MockSpannerServiceImpl;
26+
import com.google.cloud.spanner.MockSpannerServiceImpl.StatementResult;
27+
import com.google.cloud.spanner.Options;
28+
import com.google.cloud.spanner.Spanner;
29+
import com.google.cloud.spanner.SpannerOptions;
30+
import com.google.cloud.spanner.Statement;
31+
import com.google.common.base.Stopwatch;
32+
import com.google.protobuf.ByteString;
33+
import com.google.protobuf.ListValue;
34+
import com.google.protobuf.TextFormat;
35+
import com.google.spanner.v1.CacheUpdate;
36+
import com.google.spanner.v1.CreateSessionRequest;
37+
import com.google.spanner.v1.DirectedReadOptions;
38+
import com.google.spanner.v1.DirectedReadOptions.IncludeReplicas;
39+
import com.google.spanner.v1.DirectedReadOptions.ReplicaSelection;
40+
import com.google.spanner.v1.Group;
41+
import com.google.spanner.v1.Range;
42+
import com.google.spanner.v1.ReadRequest;
43+
import com.google.spanner.v1.RecipeList;
44+
import com.google.spanner.v1.ResultSet;
45+
import com.google.spanner.v1.ResultSetMetadata;
46+
import com.google.spanner.v1.Session;
47+
import com.google.spanner.v1.StructType;
48+
import com.google.spanner.v1.Tablet;
49+
import com.google.spanner.v1.Type;
50+
import com.google.spanner.v1.TypeCode;
51+
import io.grpc.Server;
52+
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
53+
import io.grpc.stub.StreamObserver;
54+
import java.io.IOException;
55+
import java.net.InetSocketAddress;
56+
import java.util.ArrayList;
57+
import java.util.Arrays;
58+
import java.util.List;
59+
import java.util.concurrent.TimeUnit;
60+
import org.junit.After;
61+
import org.junit.Before;
62+
import org.junit.Test;
63+
import org.junit.runner.RunWith;
64+
import org.junit.runners.JUnit4;
65+
66+
@RunWith(JUnit4.class)
67+
public class ReplicaSelectionMockServerTest {
68+
69+
private static final ResultSetMetadata SELECT1_METADATA =
70+
ResultSetMetadata.newBuilder()
71+
.setRowType(
72+
StructType.newBuilder()
73+
.addFields(
74+
StructType.Field.newBuilder()
75+
.setName("COL1")
76+
.setType(Type.newBuilder().setCode(TypeCode.INT64).build())
77+
.build())
78+
.build())
79+
.build();
80+
private static final ResultSet SELECT1_RESULTSET =
81+
ResultSet.newBuilder()
82+
.addRows(
83+
ListValue.newBuilder()
84+
.addValues(com.google.protobuf.Value.newBuilder().setStringValue("1").build())
85+
.build())
86+
.setMetadata(SELECT1_METADATA)
87+
.build();
88+
89+
private List<ServerInstance> servers;
90+
private final int numServers = 2;
91+
private KeyAwareChannel keyAwareChannel;
92+
93+
private static class ServerInstance {
94+
Server server;
95+
MockSpannerServiceImpl mockSpanner;
96+
int port;
97+
}
98+
99+
@Before
100+
public void setUp() throws IOException {
101+
servers = new ArrayList<>();
102+
List<MockSpannerServiceImpl> nonDefaultMocks = new ArrayList<>();
103+
for (int i = 1; i < numServers; i++) {
104+
nonDefaultMocks.add(new MockSpannerServiceImpl());
105+
}
106+
107+
MockSpannerServiceImpl defaultMock =
108+
new MockSpannerServiceImpl() {
109+
@Override
110+
public void createSession(
111+
CreateSessionRequest request, StreamObserver<Session> responseObserver) {
112+
super.createSession(
113+
request,
114+
new StreamObserver<Session>() {
115+
@Override
116+
public void onNext(Session value) {
117+
responseObserver.onNext(value);
118+
for (MockSpannerServiceImpl target : nonDefaultMocks) {
119+
target.getSessions().put(value.getName(), value);
120+
}
121+
}
122+
123+
@Override
124+
public void onError(Throwable t) {
125+
responseObserver.onError(t);
126+
}
127+
128+
@Override
129+
public void onCompleted() {
130+
responseObserver.onCompleted();
131+
}
132+
});
133+
}
134+
};
135+
136+
for (int i = 0; i < numServers; i++) {
137+
MockSpannerServiceImpl mockSpanner = (i == 0) ? defaultMock : nonDefaultMocks.get(i - 1);
138+
mockSpanner.setAbortProbability(0.0D);
139+
InetSocketAddress address = new InetSocketAddress("localhost", 0);
140+
Server server =
141+
NettyServerBuilder.forAddress(address).addService(mockSpanner).build().start();
142+
143+
ServerInstance instance = new ServerInstance();
144+
instance.server = server;
145+
instance.mockSpanner = mockSpanner;
146+
instance.port = server.getPort();
147+
servers.add(instance);
148+
}
149+
}
150+
151+
@After
152+
public void tearDown() throws InterruptedException {
153+
if (keyAwareChannel != null) {
154+
keyAwareChannel.shutdown();
155+
}
156+
for (ServerInstance si : servers) {
157+
si.server.shutdown();
158+
}
159+
for (ServerInstance si : servers) {
160+
si.server.awaitTermination(5, TimeUnit.SECONDS);
161+
}
162+
}
163+
164+
@Test
165+
public void testEndToEndWithSpannerOptions() throws Exception {
166+
SpannerOptions options =
167+
SpannerOptions.newBuilder()
168+
.usePlainText()
169+
.setExperimentalHost("localhost:" + servers.get(0).port)
170+
.setProjectId("fake-project")
171+
.setChannelEndpointCacheFactory(null)
172+
.build();
173+
174+
RecipeList.Builder recipeListBuilder = RecipeList.newBuilder();
175+
try {
176+
TextFormat.merge(
177+
"recipe {\n"
178+
+ " table_name: \"Table\"\n"
179+
+ " part { tag: 1 }\n"
180+
+ " part {\n"
181+
+ " order: ASCENDING\n"
182+
+ " null_order: NULLS_FIRST\n"
183+
+ " type { code: STRING }\n"
184+
+ " }\n"
185+
+ "}\n",
186+
recipeListBuilder);
187+
} catch (TextFormat.ParseException e) {
188+
throw new RuntimeException(e);
189+
}
190+
191+
// 2. Construct a CacheUpdate that points to Server 1 for location "us-east1"
192+
CacheUpdate cacheUpdate =
193+
CacheUpdate.newBuilder()
194+
.setDatabaseId(12345L)
195+
.setKeyRecipes(recipeListBuilder.build())
196+
.addGroup(
197+
Group.newBuilder()
198+
.setGroupUid(1L)
199+
.addTablets(
200+
Tablet.newBuilder()
201+
.setTabletUid(101L)
202+
.setServerAddress("localhost:" + servers.get(1).port)
203+
.setLocation("us-east1")
204+
.setRole(Tablet.Role.READ_ONLY)
205+
.setDistance(0)
206+
.build())
207+
.build())
208+
.addRange(
209+
Range.newBuilder()
210+
.setStartKey(ByteString.EMPTY)
211+
.setLimitKey(ByteString.copyFromUtf8("\u00FF"))
212+
.setGroupUid(1L)
213+
.setSplitId(1L)
214+
.setGeneration(ByteString.copyFromUtf8("gen1"))
215+
.build())
216+
.build();
217+
218+
ResultSet resultSetWithUpdate =
219+
SELECT1_RESULTSET.toBuilder().setCacheUpdate(cacheUpdate).build();
220+
221+
// Setup Server 0 to return the update
222+
servers
223+
.get(0)
224+
.mockSpanner
225+
.putStatementResult(StatementResult.query(Statement.of("SELECT 1"), resultSetWithUpdate));
226+
227+
com.google.cloud.spanner.Statement readStatement =
228+
StatementResult.createReadStatement(
229+
"Table",
230+
com.google.cloud.spanner.KeySet.singleKey(com.google.cloud.spanner.Key.of()),
231+
Arrays.asList("Column"));
232+
233+
// Setup Server 0 to ALSO return result for the read to avoid INTERNAL error if routing fails
234+
servers
235+
.get(0)
236+
.mockSpanner
237+
.putStatementResult(StatementResult.query(readStatement, SELECT1_RESULTSET));
238+
239+
// Setup Server 1 to return result for the directed read
240+
servers
241+
.get(1)
242+
.mockSpanner
243+
.putStatementResult(StatementResult.query(readStatement, SELECT1_RESULTSET));
244+
245+
try (Spanner spanner = options.getService()) {
246+
DatabaseClient client =
247+
spanner.getDatabaseClient(
248+
DatabaseId.of("fake-project", "fake-instance", "fake-database"));
249+
250+
// 3. Execute first query to receive the update
251+
try (com.google.cloud.spanner.ResultSet rs =
252+
client.singleUse().executeQuery(Statement.of("SELECT 1"))) {
253+
while (rs.next()) {
254+
/* consume */
255+
}
256+
}
257+
258+
// Poll until the read is routed to the replica (Server 1)
259+
String successfulKey = null;
260+
Stopwatch watch = Stopwatch.createStarted();
261+
int attempt = 0;
262+
263+
DirectedReadOptions directedReadOptions =
264+
DirectedReadOptions.newBuilder()
265+
.setIncludeReplicas(
266+
IncludeReplicas.newBuilder()
267+
.addReplicaSelections(
268+
ReplicaSelection.newBuilder().setLocation("us-east1").build())
269+
.build())
270+
.build();
271+
272+
while (watch.elapsed(TimeUnit.SECONDS) < 10) {
273+
attempt++;
274+
String key = "key-" + attempt;
275+
276+
try (com.google.cloud.spanner.ResultSet rs =
277+
client
278+
.singleUse()
279+
.read(
280+
"Table",
281+
com.google.cloud.spanner.KeySet.singleKey(com.google.cloud.spanner.Key.of(key)),
282+
Arrays.asList("Column"),
283+
Options.directedRead(directedReadOptions))) {
284+
while (rs.next()) {
285+
/* consume */
286+
}
287+
}
288+
289+
final String currentKey = key;
290+
boolean server1ReceivedRead =
291+
servers.get(1).mockSpanner.getRequestsOfType(ReadRequest.class).stream()
292+
.anyMatch(
293+
req ->
294+
req.getKeySet()
295+
.getKeys(0)
296+
.getValues(0)
297+
.getStringValue()
298+
.equals(currentKey));
299+
300+
if (server1ReceivedRead) {
301+
successfulKey = key;
302+
break;
303+
}
304+
}
305+
assertNotNull("Should have routed to replica within timeout", successfulKey);
306+
307+
// 5. Verify that Server 0 did NOT receive the read with the successful key
308+
final String finalSuccessfulKey = successfulKey;
309+
boolean server0ReceivedSuccessfulRead =
310+
servers.get(0).mockSpanner.getRequestsOfType(ReadRequest.class).stream()
311+
.anyMatch(
312+
req ->
313+
req.getKeySet()
314+
.getKeys(0)
315+
.getValues(0)
316+
.getStringValue()
317+
.equals(finalSuccessfulKey));
318+
assertFalse(
319+
"Server 0 should not have received Read with the successful key",
320+
server0ReceivedSuccessfulRead);
321+
322+
// 6. Verify that Server 1 received the read with the successful key
323+
boolean server1ReceivedSuccessfulRead =
324+
servers.get(1).mockSpanner.getRequestsOfType(ReadRequest.class).stream()
325+
.anyMatch(
326+
req ->
327+
req.getKeySet()
328+
.getKeys(0)
329+
.getValues(0)
330+
.getStringValue()
331+
.equals(finalSuccessfulKey));
332+
assertTrue(
333+
"Server 1 should have received Read with the successful key",
334+
server1ReceivedSuccessfulRead);
335+
}
336+
}
337+
}

0 commit comments

Comments
 (0)