Skip to content

Commit 4dabdac

Browse files
authored
fix(spanner): prevent fastpath tablet routing flaps (#13803)
Ignore group updates with older generations so stale tablet addresses cannot overwrite newer skip state. At equal generation, retain skip-and-empty tablets unless the incoming tablet incarnation is lexicographically newer, which permits legitimate recovery while rejecting frontend-local availability flaps. Gate endpoint recreation on active finder membership and hold the reconciliation lock through insertion so concurrent removal cannot resurrect an inactive address.
1 parent c4dc70a commit 4dabdac

8 files changed

Lines changed: 1776 additions & 42 deletions

File tree

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,9 @@ private void evictEndpoint(String address, EvictionReason reason) {
621621
* Requests that an evicted endpoint be recreated. The endpoint is created in the background and
622622
* probing starts immediately. The endpoint will only become eligible for location-aware routing
623623
* once it reaches READY state.
624+
*
625+
* <p>Recreation is refused when no finder currently lists the address as active. Route lookups
626+
* can race with cache updates that have already removed a tablet.
624627
*/
625628
void requestEndpointRecreation(String address) {
626629
if (isShutdown.get() || address == null || address.isEmpty()) {
@@ -635,9 +638,32 @@ void requestEndpointRecreation(String address) {
635638
return;
636639
}
637640

638-
logger.log(Level.FINE, "Recreating previously evicted endpoint for address: {0}", address);
639-
EndpointState state = new EndpointState(address, clock.instant());
640-
if (endpoints.putIfAbsent(address, state) == null) {
641+
boolean stillActive = false;
642+
boolean recreated = false;
643+
// Check membership and insert under the reconciliation lock so active-set removal cannot
644+
// complete between them and leave an inactive endpoint behind.
645+
synchronized (activeAddressLock) {
646+
for (Set<String> addresses : activeAddressesPerFinder.values()) {
647+
if (addresses.contains(address)) {
648+
stillActive = true;
649+
break;
650+
}
651+
}
652+
if (stillActive) {
653+
EndpointState state = new EndpointState(address, clock.instant());
654+
recreated = endpoints.putIfAbsent(address, state) == null;
655+
}
656+
}
657+
if (!stillActive) {
658+
logger.log(
659+
Level.FINE,
660+
"Skipping endpoint recreation for {0}: address is not in any finder's active set",
661+
address);
662+
return;
663+
}
664+
665+
if (recreated) {
666+
logger.log(Level.FINE, "Recreating previously evicted endpoint for address: {0}", address);
641667
// Schedule after putIfAbsent returns so the entry is visible to the scheduler thread.
642668
scheduler.submit(() -> createAndStartProbing(address));
643669
}

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

Lines changed: 243 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,35 +17,78 @@
1717
package com.google.cloud.spanner.spi.v1;
1818

1919
import com.google.common.annotations.VisibleForTesting;
20+
import io.grpc.Status;
2021
import java.time.Clock;
2122
import java.time.Duration;
2223
import java.time.Instant;
2324
import java.util.concurrent.ConcurrentHashMap;
2425
import java.util.concurrent.ThreadLocalRandom;
26+
import java.util.concurrent.atomic.AtomicBoolean;
2527
import java.util.function.LongUnaryOperator;
28+
import java.util.function.Predicate;
29+
import javax.annotation.Nullable;
2630

2731
/**
28-
* Tracks short-lived endpoint cooldowns after routed {@code RESOURCE_EXHAUSTED} failures.
32+
* Tracks short-lived endpoint cooldowns after routed overload or availability failures.
2933
*
30-
* <p>This allows later requests to try a different replica instead of immediately routing back to
31-
* the same overloaded endpoint.
34+
* <p>Server-hinted overloads and connection failures use separate escalation lanes because a short
35+
* load-shed response must not weaken protection for an unavailable endpoint.
3236
*/
33-
final class EndpointOverloadCooldownTracker {
37+
final class EndpointOverloadCooldownTracker implements Predicate<String> {
3438

3539
@VisibleForTesting static final Duration DEFAULT_INITIAL_COOLDOWN = Duration.ofSeconds(10);
3640
@VisibleForTesting static final Duration DEFAULT_MAX_COOLDOWN = Duration.ofMinutes(1);
3741
@VisibleForTesting static final Duration DEFAULT_RESET_AFTER = Duration.ofMinutes(10);
42+
@VisibleForTesting static final int DEFAULT_SUCCESSES_PER_REPAIR = 3;
43+
@VisibleForTesting static final Duration DEFAULT_MIN_HINTED_COOLDOWN = Duration.ofMillis(100);
44+
@VisibleForTesting static final Duration DEFAULT_MAX_HINTED_CLIENT_FLOOR = Duration.ofSeconds(2);
45+
@VisibleForTesting static final Duration DEFAULT_MAX_HINTED_JITTER = Duration.ofMillis(500);
46+
@VisibleForTesting static final int DEFAULT_MAX_TIER = 6;
47+
48+
private static final Duration MIN_PROBE_RESERVATION = Duration.ofMillis(250);
49+
private static final Duration MAX_PROBE_RESERVATION = Duration.ofMillis(500);
3850

3951
@VisibleForTesting
4052
static final class CooldownState {
41-
private final int consecutiveFailures;
42-
private final Instant cooldownUntil;
43-
private final Instant lastFailureAt;
44-
45-
private CooldownState(int consecutiveFailures, Instant cooldownUntil, Instant lastFailureAt) {
46-
this.consecutiveFailures = consecutiveFailures;
47-
this.cooldownUntil = cooldownUntil;
48-
this.lastFailureAt = lastFailureAt;
53+
final int overloadFailures;
54+
final int unavailableFailures;
55+
final int successesTowardRepair;
56+
final Instant overloadUntil;
57+
final Instant unavailableUntil;
58+
final Instant overloadProbeNotBefore;
59+
final Instant probeReservedUntil;
60+
@Nullable final Instant lastOverloadFailureAt;
61+
@Nullable final Instant lastUnavailableFailureAt;
62+
63+
private CooldownState(
64+
int overloadFailures,
65+
int unavailableFailures,
66+
int successesTowardRepair,
67+
Instant overloadUntil,
68+
Instant unavailableUntil,
69+
Instant overloadProbeNotBefore,
70+
Instant probeReservedUntil,
71+
@Nullable Instant lastOverloadFailureAt,
72+
@Nullable Instant lastUnavailableFailureAt) {
73+
this.overloadFailures = overloadFailures;
74+
this.unavailableFailures = unavailableFailures;
75+
this.successesTowardRepair = successesTowardRepair;
76+
this.overloadUntil = overloadUntil;
77+
this.unavailableUntil = unavailableUntil;
78+
this.overloadProbeNotBefore = overloadProbeNotBefore;
79+
this.probeReservedUntil = probeReservedUntil;
80+
this.lastOverloadFailureAt = lastOverloadFailureAt;
81+
this.lastUnavailableFailureAt = lastUnavailableFailureAt;
82+
}
83+
}
84+
85+
private static final class HintedCooldown {
86+
final Duration cooldown;
87+
final Duration probeDelay;
88+
89+
private HintedCooldown(Duration cooldown, Duration probeDelay) {
90+
this.cooldown = cooldown;
91+
this.probeDelay = probeDelay;
4992
}
5093
}
5194

@@ -94,6 +137,11 @@ private CooldownState(int consecutiveFailures, Instant cooldownUntil, Instant la
94137
randomLong == null ? bound -> ThreadLocalRandom.current().nextLong(bound) : randomLong;
95138
}
96139

140+
@Override
141+
public boolean test(String address) {
142+
return isCoolingDown(address);
143+
}
144+
97145
boolean isCoolingDown(String address) {
98146
if (address == null || address.isEmpty()) {
99147
return false;
@@ -103,33 +151,198 @@ boolean isCoolingDown(String address) {
103151
if (state == null) {
104152
return false;
105153
}
106-
if (state.cooldownUntil.isAfter(now)) {
154+
if (state.overloadUntil.isAfter(now) || state.unavailableUntil.isAfter(now)) {
107155
return true;
108156
}
109-
if (Duration.between(state.lastFailureAt, now).compareTo(resetAfter) < 0) {
157+
if (!isIdle(state, now)) {
110158
return false;
111159
}
112160
entries.remove(address, state);
113161
CooldownState current = entries.get(address);
114-
return current != null && current.cooldownUntil.isAfter(now);
162+
return current != null
163+
&& (current.overloadUntil.isAfter(now) || current.unavailableUntil.isAfter(now));
115164
}
116165

117166
void recordFailure(String address) {
118-
if (address == null || address.isEmpty()) {
167+
recordFailure(address, Status.Code.RESOURCE_EXHAUSTED, null);
168+
}
169+
170+
void recordFailure(String address, Status.Code statusCode, @Nullable Duration serverRetryDelay) {
171+
if (address == null
172+
|| address.isEmpty()
173+
|| (statusCode != Status.Code.RESOURCE_EXHAUSTED
174+
&& statusCode != Status.Code.UNAVAILABLE)) {
119175
return;
120176
}
121177
Instant now = clock.instant();
122178
entries.compute(
123179
address,
124180
(ignored, state) -> {
125-
int consecutiveFailures = 1;
126-
if (state != null
127-
&& Duration.between(state.lastFailureAt, now).compareTo(resetAfter) < 0) {
128-
consecutiveFailures = state.consecutiveFailures + 1;
181+
CooldownState current = state == null || isIdle(state, now) ? emptyState(now) : state;
182+
if (statusCode == Status.Code.RESOURCE_EXHAUSTED) {
183+
int failures =
184+
nextFailureTier(current.overloadFailures, current.lastOverloadFailureAt, now);
185+
Duration cooldown;
186+
Duration probeDelay;
187+
if (validRetryDelay(serverRetryDelay)) {
188+
HintedCooldown hinted = hintedOverloadCooldown(failures, serverRetryDelay);
189+
cooldown = hinted.cooldown;
190+
probeDelay = hinted.probeDelay;
191+
} else {
192+
cooldown = cooldownForFailures(failures);
193+
probeDelay = cooldown;
194+
}
195+
return new CooldownState(
196+
failures,
197+
current.unavailableFailures,
198+
0,
199+
later(current.overloadUntil, now.plus(cooldown)),
200+
current.unavailableUntil,
201+
later(current.overloadProbeNotBefore, now.plus(probeDelay)),
202+
current.probeReservedUntil,
203+
now,
204+
current.lastUnavailableFailureAt);
205+
}
206+
int failures =
207+
nextFailureTier(current.unavailableFailures, current.lastUnavailableFailureAt, now);
208+
return new CooldownState(
209+
current.overloadFailures,
210+
failures,
211+
0,
212+
current.overloadUntil,
213+
later(current.unavailableUntil, now.plus(cooldownForFailures(failures))),
214+
current.overloadProbeNotBefore,
215+
current.probeReservedUntil,
216+
current.lastOverloadFailureAt,
217+
now);
218+
});
219+
}
220+
221+
void recordSuccess(String address) {
222+
if (address == null || address.isEmpty()) {
223+
return;
224+
}
225+
Instant now = clock.instant();
226+
entries.computeIfPresent(
227+
address,
228+
(ignored, state) -> {
229+
if (isIdle(state, now)) {
230+
return null;
231+
}
232+
int successes = state.successesTowardRepair + 1;
233+
if (successes < DEFAULT_SUCCESSES_PER_REPAIR) {
234+
return copyWithTiersAndSuccesses(
235+
state, state.overloadFailures, state.unavailableFailures, successes);
236+
}
237+
int overloadFailures = Math.max(0, state.overloadFailures - 1);
238+
int unavailableFailures = Math.max(0, state.unavailableFailures - 1);
239+
if (overloadFailures == 0
240+
&& unavailableFailures == 0
241+
&& !state.overloadUntil.isAfter(now)
242+
&& !state.unavailableUntil.isAfter(now)) {
243+
return null;
244+
}
245+
return copyWithTiersAndSuccesses(state, overloadFailures, unavailableFailures, 0);
246+
});
247+
}
248+
249+
boolean tryReserveProbe(String address) {
250+
if (address == null || address.isEmpty()) {
251+
return false;
252+
}
253+
Instant now = clock.instant();
254+
AtomicBoolean reserved = new AtomicBoolean();
255+
entries.computeIfPresent(
256+
address,
257+
(ignored, state) -> {
258+
if (!state.overloadUntil.isAfter(now)
259+
|| state.unavailableUntil.isAfter(now)
260+
|| state.overloadProbeNotBefore.isAfter(now)
261+
|| state.probeReservedUntil.isAfter(now)) {
262+
return state;
129263
}
130-
Duration cooldown = cooldownForFailures(consecutiveFailures);
131-
return new CooldownState(consecutiveFailures, now.plus(cooldown), now);
264+
long minMillis = MIN_PROBE_RESERVATION.toMillis();
265+
long rangeMillis =
266+
MAX_PROBE_RESERVATION.toMillis() - MIN_PROBE_RESERVATION.toMillis() + 1L;
267+
Duration reservation = Duration.ofMillis(minMillis + randomLong.applyAsLong(rangeMillis));
268+
reserved.set(true);
269+
return new CooldownState(
270+
state.overloadFailures,
271+
state.unavailableFailures,
272+
state.successesTowardRepair,
273+
state.overloadUntil,
274+
state.unavailableUntil,
275+
state.overloadProbeNotBefore,
276+
now.plus(reservation),
277+
state.lastOverloadFailureAt,
278+
state.lastUnavailableFailureAt);
132279
});
280+
return reserved.get();
281+
}
282+
283+
@Nullable
284+
Instant getLastOverloadFailureAt(String address) {
285+
CooldownState state = entries.get(address);
286+
return state == null ? null : state.lastOverloadFailureAt;
287+
}
288+
289+
private CooldownState emptyState(Instant now) {
290+
return new CooldownState(0, 0, 0, now, now, now, now, null, null);
291+
}
292+
293+
private CooldownState copyWithTiersAndSuccesses(
294+
CooldownState state, int overloadFailures, int unavailableFailures, int successes) {
295+
return new CooldownState(
296+
overloadFailures,
297+
unavailableFailures,
298+
successes,
299+
state.overloadUntil,
300+
state.unavailableUntil,
301+
state.overloadProbeNotBefore,
302+
state.probeReservedUntil,
303+
state.lastOverloadFailureAt,
304+
state.lastUnavailableFailureAt);
305+
}
306+
307+
private boolean isRecent(@Nullable Instant lastFailureAt, Instant now) {
308+
return lastFailureAt != null && Duration.between(lastFailureAt, now).compareTo(resetAfter) < 0;
309+
}
310+
311+
private boolean isIdle(CooldownState state, Instant now) {
312+
return !state.overloadUntil.isAfter(now)
313+
&& !state.unavailableUntil.isAfter(now)
314+
&& !isRecent(state.lastOverloadFailureAt, now)
315+
&& !isRecent(state.lastUnavailableFailureAt, now);
316+
}
317+
318+
private int nextFailureTier(int previousFailures, @Nullable Instant lastFailureAt, Instant now) {
319+
if (previousFailures == 0 || !isRecent(lastFailureAt, now)) {
320+
return 1;
321+
}
322+
return Math.min(DEFAULT_MAX_TIER, previousFailures + 1);
323+
}
324+
325+
private static boolean validRetryDelay(@Nullable Duration retryDelay) {
326+
// A present zero delay uses the 100 ms floor; an absent hint uses unhinted backoff.
327+
return retryDelay != null && !retryDelay.isNegative();
328+
}
329+
330+
private HintedCooldown hintedOverloadCooldown(int failures, Duration serverRetryDelay) {
331+
Duration serverFloor =
332+
serverRetryDelay.compareTo(DEFAULT_MIN_HINTED_COOLDOWN) < 0
333+
? DEFAULT_MIN_HINTED_COOLDOWN
334+
: serverRetryDelay;
335+
Duration clientFloor = DEFAULT_MIN_HINTED_COOLDOWN;
336+
for (int i = 1;
337+
i < failures && clientFloor.compareTo(DEFAULT_MAX_HINTED_CLIENT_FLOOR) < 0;
338+
i++) {
339+
clientFloor = min(clientFloor.multipliedBy(2), DEFAULT_MAX_HINTED_CLIENT_FLOOR);
340+
}
341+
Duration base = serverFloor.compareTo(clientFloor) >= 0 ? serverFloor : clientFloor;
342+
Duration jitterLimit = min(base.dividedBy(4), DEFAULT_MAX_HINTED_JITTER);
343+
long jitterRangeMillis = jitterLimit.toMillis() + 1L;
344+
Duration jitter = Duration.ofMillis(randomLong.applyAsLong(jitterRangeMillis));
345+
return new HintedCooldown(base.plus(jitter), serverFloor.plus(jitter));
133346
}
134347

135348
private Duration cooldownForFailures(int failures) {
@@ -147,6 +360,14 @@ private Duration cooldownForFailures(int failures) {
147360
return Duration.ofMillis(floorMillis + randomLong.applyAsLong(rangeSize));
148361
}
149362

363+
private static Duration min(Duration first, Duration second) {
364+
return first.compareTo(second) <= 0 ? first : second;
365+
}
366+
367+
private static Instant later(Instant first, Instant second) {
368+
return first.isAfter(second) ? first : second;
369+
}
370+
150371
@VisibleForTesting
151372
CooldownState getState(String address) {
152373
return entries.get(address);

0 commit comments

Comments
 (0)