Skip to content

Commit 058b80e

Browse files
olavloiterahul2393cloud-java-bot
authored andcommitted
test(spanner): add a mock server test using key-aware routing (#12755)
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. --------- Co-authored-by: rahul2393 <rahulyadavsep92@gmail.com> Co-authored-by: cloud-java-bot <cloud-java-bot@google.com>
1 parent 178953f commit 058b80e

File tree

2 files changed

+336
-3
lines changed

2 files changed

+336
-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: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
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+
92+
private static class ServerInstance {
93+
Server server;
94+
MockSpannerServiceImpl mockSpanner;
95+
int port;
96+
}
97+
98+
@Before
99+
public void setUp() throws IOException {
100+
servers = new ArrayList<>();
101+
List<MockSpannerServiceImpl> nonDefaultMocks = new ArrayList<>();
102+
for (int i = 1; i < numServers; i++) {
103+
nonDefaultMocks.add(new MockSpannerServiceImpl());
104+
}
105+
106+
MockSpannerServiceImpl defaultMock =
107+
new MockSpannerServiceImpl() {
108+
@Override
109+
public void createSession(
110+
CreateSessionRequest request, StreamObserver<Session> responseObserver) {
111+
super.createSession(
112+
request,
113+
new StreamObserver<Session>() {
114+
@Override
115+
public void onNext(Session value) {
116+
responseObserver.onNext(value);
117+
for (MockSpannerServiceImpl target : nonDefaultMocks) {
118+
target.getSessions().put(value.getName(), value);
119+
}
120+
}
121+
122+
@Override
123+
public void onError(Throwable t) {
124+
responseObserver.onError(t);
125+
}
126+
127+
@Override
128+
public void onCompleted() {
129+
responseObserver.onCompleted();
130+
}
131+
});
132+
}
133+
};
134+
135+
for (int i = 0; i < numServers; i++) {
136+
MockSpannerServiceImpl mockSpanner = (i == 0) ? defaultMock : nonDefaultMocks.get(i - 1);
137+
mockSpanner.setAbortProbability(0.0D);
138+
InetSocketAddress address = new InetSocketAddress("localhost", 0);
139+
Server server =
140+
NettyServerBuilder.forAddress(address).addService(mockSpanner).build().start();
141+
142+
ServerInstance instance = new ServerInstance();
143+
instance.server = server;
144+
instance.mockSpanner = mockSpanner;
145+
instance.port = server.getPort();
146+
servers.add(instance);
147+
}
148+
}
149+
150+
@After
151+
public void tearDown() throws InterruptedException {
152+
for (ServerInstance si : servers) {
153+
si.server.shutdown();
154+
}
155+
for (ServerInstance si : servers) {
156+
si.server.awaitTermination(5, TimeUnit.SECONDS);
157+
}
158+
}
159+
160+
@Test
161+
public void testEndToEndWithSpannerOptions() throws Exception {
162+
SpannerOptions options =
163+
SpannerOptions.newBuilder()
164+
.usePlainText()
165+
.setExperimentalHost("localhost:" + servers.get(0).port)
166+
.setProjectId("fake-project")
167+
.setChannelEndpointCacheFactory(null)
168+
.build();
169+
170+
RecipeList.Builder recipeListBuilder = RecipeList.newBuilder();
171+
try {
172+
TextFormat.merge(
173+
"recipe {\n"
174+
+ " table_name: \"Table\"\n"
175+
+ " part { tag: 1 }\n"
176+
+ " part {\n"
177+
+ " order: ASCENDING\n"
178+
+ " null_order: NULLS_FIRST\n"
179+
+ " type { code: STRING }\n"
180+
+ " }\n"
181+
+ "}\n",
182+
recipeListBuilder);
183+
} catch (TextFormat.ParseException e) {
184+
throw new RuntimeException(e);
185+
}
186+
187+
// 2. Construct a CacheUpdate that points to Server 1 for location "us-east1"
188+
CacheUpdate cacheUpdate =
189+
CacheUpdate.newBuilder()
190+
.setDatabaseId(12345L)
191+
.setKeyRecipes(recipeListBuilder.build())
192+
.addGroup(
193+
Group.newBuilder()
194+
.setGroupUid(1L)
195+
.addTablets(
196+
Tablet.newBuilder()
197+
.setTabletUid(101L)
198+
.setServerAddress("localhost:" + servers.get(1).port)
199+
.setLocation("us-east1")
200+
.setRole(Tablet.Role.READ_ONLY)
201+
.setDistance(0)
202+
.build())
203+
.build())
204+
.addRange(
205+
Range.newBuilder()
206+
.setStartKey(ByteString.EMPTY)
207+
.setLimitKey(ByteString.copyFromUtf8("\u00FF"))
208+
.setGroupUid(1L)
209+
.setSplitId(1L)
210+
.setGeneration(ByteString.copyFromUtf8("gen1"))
211+
.build())
212+
.build();
213+
214+
ResultSet resultSetWithUpdate =
215+
SELECT1_RESULTSET.toBuilder().setCacheUpdate(cacheUpdate).build();
216+
217+
// Setup Server 0 to return the update
218+
servers
219+
.get(0)
220+
.mockSpanner
221+
.putStatementResult(StatementResult.query(Statement.of("SELECT 1"), resultSetWithUpdate));
222+
223+
com.google.cloud.spanner.Statement readStatement =
224+
StatementResult.createReadStatement(
225+
"Table",
226+
com.google.cloud.spanner.KeySet.singleKey(com.google.cloud.spanner.Key.of()),
227+
Arrays.asList("Column"));
228+
229+
// Setup Server 0 to ALSO return result for the read to avoid INTERNAL error if routing fails
230+
servers
231+
.get(0)
232+
.mockSpanner
233+
.putStatementResult(StatementResult.query(readStatement, SELECT1_RESULTSET));
234+
235+
// Setup Server 1 to return result for the directed read
236+
servers
237+
.get(1)
238+
.mockSpanner
239+
.putStatementResult(StatementResult.query(readStatement, SELECT1_RESULTSET));
240+
241+
try (Spanner spanner = options.getService()) {
242+
DatabaseClient client =
243+
spanner.getDatabaseClient(
244+
DatabaseId.of("fake-project", "fake-instance", "fake-database"));
245+
246+
// 3. Execute first query to receive the update
247+
try (com.google.cloud.spanner.ResultSet rs =
248+
client.singleUse().executeQuery(Statement.of("SELECT 1"))) {
249+
while (rs.next()) {
250+
/* consume */
251+
}
252+
}
253+
254+
// Poll until the read is routed to the replica (Server 1)
255+
String successfulKey = null;
256+
Stopwatch watch = Stopwatch.createStarted();
257+
int attempt = 0;
258+
259+
DirectedReadOptions directedReadOptions =
260+
DirectedReadOptions.newBuilder()
261+
.setIncludeReplicas(
262+
IncludeReplicas.newBuilder()
263+
.addReplicaSelections(
264+
ReplicaSelection.newBuilder().setLocation("us-east1").build())
265+
.build())
266+
.build();
267+
268+
while (watch.elapsed(TimeUnit.SECONDS) < 10) {
269+
attempt++;
270+
String key = "key-" + attempt;
271+
272+
try (com.google.cloud.spanner.ResultSet rs =
273+
client
274+
.singleUse()
275+
.read(
276+
"Table",
277+
com.google.cloud.spanner.KeySet.singleKey(com.google.cloud.spanner.Key.of(key)),
278+
Arrays.asList("Column"),
279+
Options.directedRead(directedReadOptions))) {
280+
while (rs.next()) {
281+
/* consume */
282+
}
283+
}
284+
285+
final String currentKey = key;
286+
boolean server1ReceivedRead =
287+
servers.get(1).mockSpanner.getRequestsOfType(ReadRequest.class).stream()
288+
.anyMatch(
289+
req ->
290+
req.getKeySet()
291+
.getKeys(0)
292+
.getValues(0)
293+
.getStringValue()
294+
.equals(currentKey));
295+
296+
if (server1ReceivedRead) {
297+
successfulKey = key;
298+
break;
299+
}
300+
}
301+
assertNotNull("Should have routed to replica within timeout", successfulKey);
302+
303+
// 5. Verify that Server 0 did NOT receive the read with the successful key
304+
final String finalSuccessfulKey = successfulKey;
305+
boolean server0ReceivedSuccessfulRead =
306+
servers.get(0).mockSpanner.getRequestsOfType(ReadRequest.class).stream()
307+
.anyMatch(
308+
req ->
309+
req.getKeySet()
310+
.getKeys(0)
311+
.getValues(0)
312+
.getStringValue()
313+
.equals(finalSuccessfulKey));
314+
assertFalse(
315+
"Server 0 should not have received Read with the successful key",
316+
server0ReceivedSuccessfulRead);
317+
318+
// 6. Verify that Server 1 received the read with the successful key
319+
boolean server1ReceivedSuccessfulRead =
320+
servers.get(1).mockSpanner.getRequestsOfType(ReadRequest.class).stream()
321+
.anyMatch(
322+
req ->
323+
req.getKeySet()
324+
.getKeys(0)
325+
.getValues(0)
326+
.getStringValue()
327+
.equals(finalSuccessfulKey));
328+
assertTrue(
329+
"Server 1 should have received Read with the successful key",
330+
server1ReceivedSuccessfulRead);
331+
}
332+
}
333+
}

0 commit comments

Comments
 (0)