Skip to content

Commit 6c457b3

Browse files
committed
fix(spanner): prevent fastpath tablet routing flaps
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 acff1f2 commit 6c457b3

4 files changed

Lines changed: 418 additions & 6 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/KeyRangeCache.java

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -710,9 +710,16 @@ private class CachedGroup {
710710

711711
void update(Group groupIn) {
712712
GroupSnapshot current = snapshot;
713+
int generationCmp = compare(groupIn.getGeneration(), current.generation);
714+
// Response-carried cache updates may arrive out of order. Older group information must not
715+
// overwrite newer tablet membership and reintroduce an unavailable tablet.
716+
if (generationCmp < 0) {
717+
return;
718+
}
719+
713720
ByteString generation = current.generation;
714721
int leaderIndex = current.leaderIndex;
715-
if (compare(groupIn.getGeneration(), generation) > 0) {
722+
if (generationCmp > 0) {
716723
generation = groupIn.getGeneration();
717724
if (groupIn.getLeaderIndex() >= 0 && groupIn.getLeaderIndex() < groupIn.getTabletsCount()) {
718725
leaderIndex = groupIn.getLeaderIndex();
@@ -721,11 +728,48 @@ void update(Group groupIn) {
721728
}
722729
}
723730

731+
List<TabletSnapshot> tablets;
732+
if (generationCmp == 0 && !current.tablets.isEmpty()) {
733+
// Frontend-local location caches can disagree about availability at the same Paxos
734+
// generation. Tablet incarnation is the authoritative per-tablet freshness signal.
735+
tablets = mergeEqualGenerationTablets(current.tablets, groupIn);
736+
} else {
737+
tablets = new ArrayList<>(groupIn.getTabletsCount());
738+
for (int t = 0; t < groupIn.getTabletsCount(); t++) {
739+
tablets.add(new TabletSnapshot(groupIn.getTablets(t)));
740+
}
741+
}
742+
snapshot = new GroupSnapshot(generation, leaderIndex, tablets);
743+
}
744+
745+
/**
746+
* Keeps a skipped tablet latched at an equal group generation unless fresher tablet metadata
747+
* restores its address.
748+
*/
749+
private List<TabletSnapshot> mergeEqualGenerationTablets(
750+
List<TabletSnapshot> currentTablets, Group groupIn) {
751+
Map<Long, TabletSnapshot> currentByUid = new HashMap<>();
752+
for (TabletSnapshot tablet : currentTablets) {
753+
currentByUid.put(tablet.tabletUid, tablet);
754+
}
755+
724756
List<TabletSnapshot> tablets = new ArrayList<>(groupIn.getTabletsCount());
725757
for (int t = 0; t < groupIn.getTabletsCount(); t++) {
726-
tablets.add(new TabletSnapshot(groupIn.getTablets(t)));
758+
TabletSnapshot incoming = new TabletSnapshot(groupIn.getTablets(t));
759+
TabletSnapshot previous = currentByUid.get(incoming.tabletUid);
760+
boolean restoresSkippedTablet =
761+
previous != null
762+
&& previous.skip
763+
&& previous.serverAddress.isEmpty()
764+
&& !incoming.skip
765+
&& !incoming.serverAddress.isEmpty();
766+
boolean hasNewerIncarnation =
767+
restoresSkippedTablet
768+
&& !incoming.incarnation.isEmpty()
769+
&& compare(incoming.incarnation, previous.incarnation) > 0;
770+
tablets.add(restoresSkippedTablet && !hasNewerIncarnation ? previous : incoming);
727771
}
728-
snapshot = new GroupSnapshot(generation, leaderIndex, tablets);
772+
return tablets;
729773
}
730774

731775
RouteLookupResult lookupRoutingHint(

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

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package com.google.cloud.spanner.spi.v1;
1818

19+
import static org.awaitility.Awaitility.await;
1920
import static org.junit.Assert.assertEquals;
2021
import static org.junit.Assert.assertFalse;
2122
import static org.junit.Assert.assertNotNull;
@@ -29,7 +30,9 @@
2930
import java.util.Collections;
3031
import java.util.HashSet;
3132
import java.util.Set;
33+
import java.util.concurrent.CountDownLatch;
3234
import java.util.concurrent.TimeUnit;
35+
import java.util.concurrent.atomic.AtomicBoolean;
3336
import java.util.concurrent.atomic.AtomicLong;
3437
import java.util.concurrent.locks.LockSupport;
3538
import java.util.function.BooleanSupplier;
@@ -418,6 +421,80 @@ public void staleEndpointEvictedWhenNoLongerActive() throws Exception {
418421
assertEquals(1, manager.managedEndpointCount());
419422
}
420423

424+
@Test
425+
public void requestEndpointRecreationSkippedWhenAddressNotActive() throws Exception {
426+
KeyRangeCacheTest.FakeEndpointCache cache = new KeyRangeCacheTest.FakeEndpointCache();
427+
manager =
428+
new EndpointLifecycleManager(
429+
cache, /* probeIntervalSeconds= */ 60, Duration.ofMinutes(30), Clock.systemUTC());
430+
431+
String finder1 = registerAddresses(manager, "server1", "server2");
432+
awaitCondition(
433+
"endpoints should be created",
434+
() -> cache.getIfPresent("server1") != null && cache.getIfPresent("server2") != null);
435+
436+
// Remove server1 from the active set (stale-evict).
437+
manager.updateActiveAddresses(finder1, Collections.singleton("server2"));
438+
assertFalse(manager.isManaged("server1"));
439+
assertNull(cache.getIfPresent("server1"));
440+
441+
// Route-lookup recreation must not revive a non-active address.
442+
manager.requestEndpointRecreation("server1");
443+
await()
444+
.atMost(Duration.ofSeconds(5))
445+
.until(
446+
() -> !manager.isManaged("server1") && cache.getIfPresent("server1") == null);
447+
}
448+
449+
@Test
450+
public void requestEndpointRecreationDoesNotRaceActiveAddressRemoval() throws Exception {
451+
KeyRangeCacheTest.FakeEndpointCache cache = new KeyRangeCacheTest.FakeEndpointCache();
452+
BlockingClock clock = new BlockingClock(Instant.now());
453+
manager =
454+
new EndpointLifecycleManager(cache, /* probeIntervalSeconds= */ 60, Duration.ZERO, clock);
455+
456+
String finder = registerAddresses(manager, "server1");
457+
awaitCondition(
458+
"endpoint should be created in background", () -> cache.getIfPresent("server1") != null);
459+
clock.advance(Duration.ofSeconds(1));
460+
manager.checkIdleEviction();
461+
assertFalse(manager.isManaged("server1"));
462+
463+
clock.blockThread("endpoint-recreation");
464+
Thread recreation =
465+
new Thread(() -> manager.requestEndpointRecreation("server1"), "endpoint-recreation");
466+
recreation.start();
467+
assertTrue("recreation should reach endpoint insertion", clock.awaitBlocked());
468+
469+
AtomicBoolean removalDone = new AtomicBoolean();
470+
Thread removal =
471+
new Thread(
472+
() -> {
473+
manager.updateActiveAddresses(finder, Collections.emptySet());
474+
removalDone.set(true);
475+
},
476+
"active-address-removal");
477+
removal.start();
478+
479+
long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
480+
while (!removalDone.get()
481+
&& removal.getState() != Thread.State.BLOCKED
482+
&& System.nanoTime() < deadlineNanos) {
483+
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10));
484+
}
485+
assertTrue(
486+
"removal should either finish or block on the active-address lock",
487+
removalDone.get() || removal.getState() == Thread.State.BLOCKED);
488+
489+
clock.releaseBlockedThread();
490+
recreation.join(TimeUnit.SECONDS.toMillis(5));
491+
removal.join(TimeUnit.SECONDS.toMillis(5));
492+
493+
assertFalse("recreation thread should finish", recreation.isAlive());
494+
assertFalse("removal thread should finish", removal.isAlive());
495+
assertFalse("inactive address must not be resurrected", manager.isManaged("server1"));
496+
}
497+
421498
@Test
422499
public void endpointKeptIfReferencedByAnotherFinder() throws Exception {
423500
KeyRangeCacheTest.FakeEndpointCache cache = new KeyRangeCacheTest.FakeEndpointCache();
@@ -485,4 +562,58 @@ public Clock withZone(ZoneId zone) {
485562
return this;
486563
}
487564
}
565+
566+
/** Clock that can pause one named thread inside endpoint-state construction. */
567+
private static final class BlockingClock extends Clock {
568+
private Instant now;
569+
private final CountDownLatch blocked = new CountDownLatch(1);
570+
private final CountDownLatch release = new CountDownLatch(1);
571+
private volatile String blockedThreadName;
572+
573+
BlockingClock(Instant now) {
574+
this.now = now;
575+
}
576+
577+
void blockThread(String threadName) {
578+
this.blockedThreadName = threadName;
579+
}
580+
581+
void advance(Duration duration) {
582+
now = now.plus(duration);
583+
}
584+
585+
boolean awaitBlocked() throws InterruptedException {
586+
return blocked.await(5, TimeUnit.SECONDS);
587+
}
588+
589+
void releaseBlockedThread() {
590+
release.countDown();
591+
}
592+
593+
@Override
594+
public Instant instant() {
595+
if (Thread.currentThread().getName().equals(blockedThreadName)) {
596+
blocked.countDown();
597+
try {
598+
if (!release.await(5, TimeUnit.SECONDS)) {
599+
throw new AssertionError("timed out waiting to release blocked clock");
600+
}
601+
} catch (InterruptedException e) {
602+
Thread.currentThread().interrupt();
603+
throw new AssertionError(e);
604+
}
605+
}
606+
return now;
607+
}
608+
609+
@Override
610+
public ZoneId getZone() {
611+
return ZoneId.of("UTC");
612+
}
613+
614+
@Override
615+
public Clock withZone(ZoneId zone) {
616+
return this;
617+
}
618+
}
488619
}

0 commit comments

Comments
 (0)