Skip to content

Commit f91dbba

Browse files
authored
chore: normalize query param keys to lowercase in KeyRecipe and fix lock contention in KeyRangeCache (#4380)
* chore: normalize query param keys to lowercase in KeyRecipe * add tests
1 parent e238990 commit f91dbba

File tree

3 files changed

+148
-7
lines changed

3 files changed

+148
-7
lines changed

google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRangeCache.java

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -559,29 +559,68 @@ synchronized void update(Group groupIn) {
559559
tablets = newTablets;
560560
}
561561

562-
synchronized ChannelEndpoint fillRoutingHint(
562+
ChannelEndpoint fillRoutingHint(
563563
boolean preferLeader,
564564
DirectedReadOptions directedReadOptions,
565565
RoutingHint.Builder hintBuilder) {
566566
boolean hasDirectedReadOptions =
567567
directedReadOptions.getReplicasCase()
568568
!= DirectedReadOptions.ReplicasCase.REPLICAS_NOT_SET;
569+
570+
// Fast path: pick a tablet while holding the lock. If the endpoint is already
571+
// cached on the tablet, return it immediately without releasing the lock.
572+
// If the endpoint needs to be created (blocking network dial), release the
573+
// lock first so other threads are not blocked during channel creation.
574+
CachedTablet selected;
575+
synchronized (this) {
576+
selected =
577+
selectTabletLocked(
578+
preferLeader, hasDirectedReadOptions, hintBuilder, directedReadOptions);
579+
if (selected == null) {
580+
return null;
581+
}
582+
if (selected.endpoint != null || selected.serverAddress.isEmpty()) {
583+
return selected.pick(hintBuilder);
584+
}
585+
// Slow path: endpoint not yet created. Capture the address and release the
586+
// lock before calling endpointCache.get(), which may block on network dial.
587+
hintBuilder.setTabletUid(selected.tabletUid);
588+
}
589+
590+
String serverAddress = selected.serverAddress;
591+
ChannelEndpoint endpoint = endpointCache.get(serverAddress);
592+
593+
synchronized (this) {
594+
// Only update if the tablet's address hasn't changed since we released the lock.
595+
if (selected.endpoint == null && selected.serverAddress.equals(serverAddress)) {
596+
selected.endpoint = endpoint;
597+
}
598+
// Re-set tabletUid with the latest value in case update() ran concurrently.
599+
hintBuilder.setTabletUid(selected.tabletUid);
600+
return selected.endpoint;
601+
}
602+
}
603+
604+
private CachedTablet selectTabletLocked(
605+
boolean preferLeader,
606+
boolean hasDirectedReadOptions,
607+
RoutingHint.Builder hintBuilder,
608+
DirectedReadOptions directedReadOptions) {
569609
if (preferLeader
570610
&& !hasDirectedReadOptions
571611
&& hasLeader()
572612
&& leader().distance <= MAX_LOCAL_REPLICA_DISTANCE
573613
&& !leader().shouldSkip(hintBuilder)) {
574-
return leader().pick(hintBuilder);
614+
return leader();
575615
}
576-
577616
for (CachedTablet tablet : tablets) {
578617
if (!tablet.matches(directedReadOptions)) {
579618
continue;
580619
}
581620
if (tablet.shouldSkip(hintBuilder)) {
582621
continue;
583622
}
584-
return tablet.pick(hintBuilder);
623+
return tablet;
585624
}
586625
return null;
587626
}

google-cloud-spanner/src/main/java/com/google/cloud/spanner/spi/v1/KeyRecipe.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@
3030
import java.time.format.ResolverStyle;
3131
import java.util.ArrayList;
3232
import java.util.Base64;
33+
import java.util.Collections;
34+
import java.util.HashMap;
3335
import java.util.List;
36+
import java.util.Locale;
37+
import java.util.Map;
3438
import java.util.concurrent.ThreadLocalRandom;
3539
import java.util.function.BiFunction;
3640
import java.util.stream.Collectors;
@@ -780,10 +784,26 @@ public TargetRange keySetToTargetRange(KeySet in) {
780784
}
781785

782786
public TargetRange queryParamsToTargetRange(Struct in) {
787+
// toLowerCase(Locale.ROOT) is safe for query parameter names, even for non-ASCII
788+
// characters such as the Turkish upper-case İ (U+0130). Query parameter names cannot
789+
// be quoted in Spanner SQL (the @paramName syntax imposes an unquoted identifier
790+
// grammar), so both the identifier sent by the server in the KeyRecipe and the
791+
// parameter name bound by the user must follow the same syntax rules. Applying the
792+
// same Locale.ROOT case-folding to both sides guarantees a consistent match.
793+
// If the server were to normalize identifiers differently, the only consequence is
794+
// a routing miss and graceful fallback to the default endpoint — not a query failure.
795+
//
796+
// Sort field names before inserting into the map so that when two param names
797+
// collide after case-folding (e.g. "Id" vs "ID") the winner is deterministic,
798+
// matching the Go implementation.
799+
List<String> fieldNames = new ArrayList<>(in.getFieldsMap().keySet());
800+
Collections.sort(fieldNames);
801+
final Map<String, Value> lowercaseFields = new HashMap<>(fieldNames.size());
802+
for (String fieldName : fieldNames) {
803+
lowercaseFields.put(fieldName.toLowerCase(Locale.ROOT), in.getFieldsMap().get(fieldName));
804+
}
783805
return encodeKeyInternal(
784-
(index, identifier) -> {
785-
return in.getFieldsMap().get(identifier);
786-
},
806+
(index, identifier) -> lowercaseFields.get(identifier.toLowerCase(Locale.ROOT)),
787807
KeyType.FULL_KEY);
788808
}
789809

google-cloud-spanner/src/test/java/com/google/cloud/spanner/spi/v1/KeyRecipeTest.java

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.google.cloud.spanner.spi.v1;
1818

1919
import static org.junit.Assert.assertEquals;
20+
import static org.junit.Assert.assertFalse;
2021
import static org.junit.Assert.assertTrue;
2122

2223
import com.google.protobuf.ByteString;
@@ -76,6 +77,87 @@ public void queryParamsUsesConstantValue() throws Exception {
7677
assertTrue(target.limit.isEmpty());
7778
}
7879

80+
@Test
81+
public void queryParamsCaseInsensitiveFallback() throws Exception {
82+
com.google.spanner.v1.KeyRecipe recipeProto =
83+
createRecipe(
84+
"part { tag: 1 }\n"
85+
+ "part {\n"
86+
+ " order: ASCENDING\n"
87+
+ " null_order: NULLS_FIRST\n"
88+
+ " type { code: STRING }\n"
89+
+ " identifier: \"id\"\n"
90+
+ "}\n");
91+
92+
Struct params =
93+
parseStruct(
94+
"fields {\n" + " key: \"Id\"\n" + " value { string_value: \"foo\" }\n" + "}\n");
95+
96+
KeyRecipe recipe = KeyRecipe.create(recipeProto);
97+
TargetRange target = recipe.queryParamsToTargetRange(params);
98+
assertEquals(expectedKey("foo"), target.start);
99+
assertTrue(target.limit.isEmpty());
100+
}
101+
102+
@Test
103+
public void queryParamsCaseInsensitiveDuplicateUsesLastValue() throws Exception {
104+
com.google.spanner.v1.KeyRecipe recipeProto =
105+
createRecipe(
106+
"part { tag: 1 }\n"
107+
+ "part {\n"
108+
+ " order: ASCENDING\n"
109+
+ " null_order: NULLS_FIRST\n"
110+
+ " type { code: STRING }\n"
111+
+ " identifier: \"ID\"\n"
112+
+ "}\n");
113+
114+
// Both "Id" and "id" normalize to "id"; the last one ("id"→"bar") wins.
115+
Struct params =
116+
parseStruct(
117+
"fields {\n"
118+
+ " key: \"Id\"\n"
119+
+ " value { string_value: \"foo\" }\n"
120+
+ "}\n"
121+
+ "fields {\n"
122+
+ " key: \"id\"\n"
123+
+ " value { string_value: \"bar\" }\n"
124+
+ "}\n");
125+
126+
KeyRecipe recipe = KeyRecipe.create(recipeProto);
127+
TargetRange target = recipe.queryParamsToTargetRange(params);
128+
assertEquals(expectedKey("bar"), target.start);
129+
assertFalse(target.approximate);
130+
assertTrue(target.limit.isEmpty());
131+
}
132+
133+
@Test
134+
public void queryParamsCaseInsensitiveSafeForTurkishDotI() throws Exception {
135+
// Turkish upper-case İ (U+0130) lower-cases to two characters under Locale.ROOT
136+
// (i + combining dot above), so "SİCİL".length() != "SİCİL".toLowerCase(ROOT).length()
137+
// and "SİCİL".equalsIgnoreCase("SİCİL".toLowerCase(ROOT)) is false.
138+
// This is still safe because both the recipe identifier (server-sent) and the user's
139+
// bound parameter name go through the same Locale.ROOT lower-casing before the
140+
// HashMap lookup, so they produce the same string on both sides and the match succeeds.
141+
com.google.spanner.v1.KeyRecipe recipeProto =
142+
createRecipe(
143+
"part { tag: 1 }\n"
144+
+ "part {\n"
145+
+ " order: ASCENDING\n"
146+
+ " null_order: NULLS_FIRST\n"
147+
+ " type { code: STRING }\n"
148+
+ " identifier: \"SİCİL\"\n"
149+
+ "}\n");
150+
151+
Struct params =
152+
parseStruct(
153+
"fields {\n" + " key: \"SİCİL\"\n" + " value { string_value: \"test\" }\n" + "}\n");
154+
155+
KeyRecipe recipe = KeyRecipe.create(recipeProto);
156+
TargetRange target = recipe.queryParamsToTargetRange(params);
157+
assertEquals(expectedKey("test"), target.start);
158+
assertTrue(target.limit.isEmpty());
159+
}
160+
79161
private static com.google.spanner.v1.KeyRecipe createRecipe(String text)
80162
throws TextFormat.ParseException {
81163
com.google.spanner.v1.KeyRecipe.Builder builder = com.google.spanner.v1.KeyRecipe.newBuilder();

0 commit comments

Comments
 (0)