blob: e23e740b89312ebb8e57ebb1f1a38fb208680532 [file]
/*
Copyright 2026 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package spanner
import (
"context"
"fmt"
"sync/atomic"
"testing"
"time"
sppb "cloud.google.com/go/spanner/apiv1/spannerpb"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
)
type testEndpoint struct {
address string
healthy bool
transientFailure bool
conn *grpc.ClientConn
active atomic.Int64
}
func (e *testEndpoint) Address() string {
return e.address
}
func (e *testEndpoint) IsHealthy() bool {
if e.conn != nil {
return e.conn.GetState() == connectivity.Ready
}
return e.healthy
}
func (e *testEndpoint) IsTransientFailure() bool {
if e.conn != nil {
return e.conn.GetState() == connectivity.TransientFailure
}
return e.transientFailure
}
func (e *testEndpoint) GetConn() *grpc.ClientConn {
return e.conn
}
func (e *testEndpoint) IncrementActiveRequests() {
e.active.Add(1)
}
func (e *testEndpoint) DecrementActiveRequests() {
e.active.Add(-1)
}
func (e *testEndpoint) ActiveRequestCount() int {
return int(e.active.Load())
}
type testEndpointCache struct {
endpoints map[string]*testEndpoint
onGet func(address string)
}
func newTestEndpointCache() *testEndpointCache {
return &testEndpointCache{endpoints: make(map[string]*testEndpoint)}
}
func (c *testEndpointCache) ClientFor(_ channelEndpoint) spannerClient { return nil }
func (c *testEndpointCache) Close() error { return nil }
func (c *testEndpointCache) DefaultChannel() channelEndpoint {
return &testEndpoint{address: "", healthy: true}
}
func (c *testEndpointCache) Get(_ context.Context, address string) channelEndpoint {
if c.onGet != nil {
c.onGet(address)
}
if endpoint, ok := c.endpoints[address]; ok {
return endpoint
}
endpoint := &testEndpoint{address: address, healthy: true}
c.endpoints[address] = endpoint
return endpoint
}
func (c *testEndpointCache) GetIfPresent(address string) channelEndpoint {
endpoint, ok := c.endpoints[address]
if !ok {
return nil
}
return endpoint
}
func (c *testEndpointCache) Evict(address string) {
delete(c.endpoints, address)
}
func (c *testEndpointCache) setHealthy(address string, healthy bool) {
if endpoint, ok := c.endpoints[address]; ok {
endpoint.healthy = healthy
}
}
func (c *testEndpointCache) setTransientFailure(address string, transientFailure bool) {
if endpoint, ok := c.endpoints[address]; ok {
endpoint.transientFailure = transientFailure
}
}
func TestCachedTabletMatches_DirectedReadOptionsWrappers(t *testing.T) {
tablet := &cachedTablet{
distance: 1,
role: sppb.Tablet_READ_ONLY,
location: "us-east1",
}
include := &sppb.DirectedReadOptions{
Replicas: &sppb.DirectedReadOptions_IncludeReplicas_{
IncludeReplicas: &sppb.DirectedReadOptions_IncludeReplicas{
ReplicaSelections: []*sppb.DirectedReadOptions_ReplicaSelection{
{Location: "us-east1", Type: sppb.DirectedReadOptions_ReplicaSelection_READ_ONLY},
},
},
},
}
if !tablet.matches(include) {
t.Fatal("expected include_replicas selection to match tablet")
}
exclude := &sppb.DirectedReadOptions{
Replicas: &sppb.DirectedReadOptions_ExcludeReplicas_{
ExcludeReplicas: &sppb.DirectedReadOptions_ExcludeReplicas{
ReplicaSelections: []*sppb.DirectedReadOptions_ReplicaSelection{
{Location: "us-east1", Type: sppb.DirectedReadOptions_ReplicaSelection_READ_ONLY},
},
},
},
}
if tablet.matches(exclude) {
t.Fatal("expected exclude_replicas selection to reject tablet")
}
}
func TestCachedGroupFillRoutingHint_PreferLeaderDisabledByDirectedReadOptions(t *testing.T) {
group := &cachedGroup{
leaderIdx: 0,
tablets: []*cachedTablet{
{
tabletUID: 1,
serverAddress: "leader",
distance: 1,
role: sppb.Tablet_READ_WRITE,
location: "us-central1",
},
{
tabletUID: 2,
serverAddress: "replica",
distance: 1,
role: sppb.Tablet_READ_ONLY,
location: "us-east1",
},
},
}
endpoint := group.fillRoutingHint(
context.Background(),
newPassthroughChannelEndpointCache(),
true,
&sppb.DirectedReadOptions{},
&sppb.RoutingHint{},
)
if endpoint == nil || endpoint.Address() != "leader" {
t.Fatalf("expected leader for default reads, got %#v", endpoint)
}
directedRead := &sppb.DirectedReadOptions{
Replicas: &sppb.DirectedReadOptions_IncludeReplicas_{
IncludeReplicas: &sppb.DirectedReadOptions_IncludeReplicas{
ReplicaSelections: []*sppb.DirectedReadOptions_ReplicaSelection{
{Location: "us-east1", Type: sppb.DirectedReadOptions_ReplicaSelection_READ_ONLY},
},
},
},
}
endpoint = group.fillRoutingHint(
context.Background(),
newPassthroughChannelEndpointCache(),
true,
directedRead,
&sppb.RoutingHint{},
)
if endpoint == nil || endpoint.Address() != "replica" {
t.Fatalf("expected directed read to route to replica, got %#v", endpoint)
}
}
func TestKeyRangeCache_FillRoutingHintSkipsUnhealthyCachedTablet(t *testing.T) {
endpointCache := newTestEndpointCache()
cache := newKeyRangeCache(endpointCache)
cache.addRanges(&sppb.CacheUpdate{
Range: []*sppb.Range{
{
StartKey: []byte("a"),
LimitKey: []byte("z"),
GroupUid: 5,
SplitId: 1,
Generation: []byte("1"),
},
},
Group: []*sppb.Group{
{
GroupUid: 5,
Generation: []byte("1"),
LeaderIndex: 0,
Tablets: []*sppb.Tablet{
{
TabletUid: 1,
ServerAddress: "server1",
Incarnation: []byte("1"),
Distance: 0,
},
{
TabletUid: 2,
ServerAddress: "server2",
Incarnation: []byte("1"),
Distance: 0,
},
},
},
},
})
initialHint := &sppb.RoutingHint{Key: []byte("a")}
initialEndpoint := cache.fillRoutingHint(context.Background(), false, rangeModeCoveringSplit, &sppb.DirectedReadOptions{}, initialHint)
if initialEndpoint == nil {
t.Fatal("expected initial endpoint")
}
endpointCache.setHealthy("server1", false)
hint := &sppb.RoutingHint{Key: []byte("a")}
endpoint := cache.fillRoutingHint(context.Background(), false, rangeModeCoveringSplit, &sppb.DirectedReadOptions{}, hint)
if endpoint == nil || endpoint.Address() != "server2" {
t.Fatalf("expected server2 after server1 became unhealthy, got %#v", endpoint)
}
if len(hint.GetSkippedTabletUid()) != 0 {
t.Fatalf("expected unhealthy non-transient tablet to be skipped silently, got %#v", hint.GetSkippedTabletUid())
}
}
func TestKeyRangeCache_FillRoutingHintSkipsTransientFailureTablet(t *testing.T) {
endpointCache := newTestEndpointCache()
cache := newKeyRangeCache(endpointCache)
manager := newEndpointLifecycleManagerWithOptions(endpointCache, time.Hour, time.Hour, time.Now)
defer manager.shutdown()
cache.setLifecycleManager(manager)
cache.addRanges(&sppb.CacheUpdate{
Range: []*sppb.Range{
{
StartKey: []byte("a"),
LimitKey: []byte("z"),
GroupUid: 5,
SplitId: 1,
Generation: []byte("1"),
},
},
Group: []*sppb.Group{
{
GroupUid: 5,
Generation: []byte("1"),
LeaderIndex: 0,
Tablets: []*sppb.Tablet{
{
TabletUid: 1,
ServerAddress: "server1",
Incarnation: []byte("1"),
Distance: 0,
},
{
TabletUid: 2,
ServerAddress: "server2",
Incarnation: []byte("1"),
Distance: 0,
},
},
},
},
})
endpointCache.Get(context.Background(), "server1")
endpointCache.Get(context.Background(), "server2")
endpointCache.setHealthy("server1", false)
endpointCache.setTransientFailure("server1", true)
hint := &sppb.RoutingHint{Key: []byte("a")}
endpoint := cache.fillRoutingHint(context.Background(), false, rangeModeCoveringSplit, &sppb.DirectedReadOptions{}, hint)
if endpoint == nil || endpoint.Address() != "server2" {
t.Fatalf("expected server2 after transient failure on server1, got %#v", endpoint)
}
if len(hint.GetSkippedTabletUid()) != 1 || hint.GetSkippedTabletUid()[0].GetTabletUid() != 1 {
t.Fatalf("expected tablet 1 to be marked skipped, got %#v", hint.GetSkippedTabletUid())
}
waitForCondition(t, time.Second, func() bool {
return manager.isManaged("server1")
})
}
func TestKeyRangeCache_FillRoutingHintRecordsKnownTransientFailure(t *testing.T) {
endpointCache := newTestEndpointCache()
cache := newKeyRangeCache(endpointCache)
cache.addRanges(&sppb.CacheUpdate{
Range: []*sppb.Range{
{
StartKey: []byte("a"),
LimitKey: []byte("z"),
GroupUid: 5,
SplitId: 1,
Generation: []byte("1"),
},
},
Group: []*sppb.Group{
{
GroupUid: 5,
Generation: []byte("1"),
LeaderIndex: 0,
Tablets: []*sppb.Tablet{
{
TabletUid: 1,
ServerAddress: "server-leader",
Incarnation: []byte("1"),
Distance: 0,
},
{
TabletUid: 2,
ServerAddress: "server-failed",
Incarnation: []byte("1"),
Distance: 0,
},
},
},
},
})
endpointCache.Get(context.Background(), "server-leader")
endpointCache.Get(context.Background(), "server-failed")
endpointCache.setHealthy("server-failed", false)
endpointCache.setTransientFailure("server-failed", true)
hint := &sppb.RoutingHint{Key: []byte("a")}
endpoint := cache.fillRoutingHint(context.Background(), true, rangeModeCoveringSplit, &sppb.DirectedReadOptions{}, hint)
if endpoint == nil || endpoint.Address() != "server-leader" {
t.Fatalf("expected leader endpoint, got %#v", endpoint)
}
if len(hint.GetSkippedTabletUid()) != 1 || hint.GetSkippedTabletUid()[0].GetTabletUid() != 2 {
t.Fatalf("expected tablet 2 to be marked skipped, got %#v", hint.GetSkippedTabletUid())
}
}
func TestKeyRangeCache_FillRoutingHintRecordsRecentlyEvictedTransientFailure(t *testing.T) {
endpointCache := newTestEndpointCache()
cache := newKeyRangeCache(endpointCache)
manager := newEndpointLifecycleManagerWithOptions(endpointCache, time.Hour, time.Hour, time.Now)
defer manager.shutdown()
cache.setLifecycleManager(manager)
manager.mu.Lock()
manager.transientFailureEvictedAddresses["server-failed"] = struct{}{}
manager.mu.Unlock()
cache.addRanges(&sppb.CacheUpdate{
Range: []*sppb.Range{
{
StartKey: []byte("a"),
LimitKey: []byte("z"),
GroupUid: 5,
SplitId: 1,
Generation: []byte("1"),
},
},
Group: []*sppb.Group{
{
GroupUid: 5,
Generation: []byte("1"),
LeaderIndex: 0,
Tablets: []*sppb.Tablet{
{
TabletUid: 1,
ServerAddress: "server-leader",
Incarnation: []byte("1"),
Distance: 0,
},
{
TabletUid: 2,
ServerAddress: "server-failed",
Incarnation: []byte("1"),
Distance: 0,
},
},
},
},
})
endpointCache.Get(context.Background(), "server-leader")
hint := &sppb.RoutingHint{Key: []byte("a")}
endpoint := cache.fillRoutingHint(context.Background(), true, rangeModeCoveringSplit, &sppb.DirectedReadOptions{}, hint)
if endpoint == nil || endpoint.Address() != "server-leader" {
t.Fatalf("expected leader endpoint, got %#v", endpoint)
}
if len(hint.GetSkippedTabletUid()) != 1 || hint.GetSkippedTabletUid()[0].GetTabletUid() != 2 {
t.Fatalf("expected tablet 2 to be marked skipped, got %#v", hint.GetSkippedTabletUid())
}
}
func TestKeyRangeCache_FillRoutingHintWithDetailsTracksSelectionAndSkipReasons(t *testing.T) {
endpointCache := newTestEndpointCache()
cache := newKeyRangeCache(endpointCache)
cache.addRanges(&sppb.CacheUpdate{
Range: []*sppb.Range{
{
StartKey: []byte("a"),
LimitKey: []byte("z"),
GroupUid: 5,
SplitId: 1,
Generation: []byte("1"),
},
},
Group: []*sppb.Group{
{
GroupUid: 5,
Generation: []byte("1"),
LeaderIndex: 3,
Tablets: []*sppb.Tablet{
{
TabletUid: 1,
ServerAddress: "server-first",
Incarnation: []byte("1"),
Distance: 0,
},
{
TabletUid: 3,
ServerAddress: "server-transient",
Incarnation: []byte("1"),
Distance: 0,
},
{
TabletUid: 4,
ServerAddress: "server-excluded",
Incarnation: []byte("1"),
Distance: 0,
},
{
TabletUid: 2,
ServerAddress: "server-leader",
Incarnation: []byte("1"),
Distance: 0,
},
},
},
},
})
endpointCache.Get(context.Background(), "server-first")
endpointCache.Get(context.Background(), "server-leader")
endpointCache.Get(context.Background(), "server-transient")
endpointCache.setHealthy("server-first", false)
endpointCache.setHealthy("server-transient", false)
endpointCache.setTransientFailure("server-transient", true)
cooldowns := newEndpointOverloadCooldownTrackerWithOptions(
time.Minute,
time.Minute,
10*time.Minute,
time.Now,
func(n int64) int64 { return n - 1 },
)
cooldowns.recordFailure("server-excluded")
hint := &sppb.RoutingHint{Key: []byte("a")}
endpoint := cache.fillRoutingHintWithCooldownTracker(
context.Background(),
false,
rangeModeCoveringSplit,
&sppb.DirectedReadOptions{},
hint,
cooldowns,
)
if endpoint == nil || endpoint.Address() != "server-leader" {
t.Fatalf("expected server-leader endpoint, got %#v", endpoint)
}
if got, want := len(hint.GetSkippedTabletUid()), 1; got != want {
t.Fatalf("len(skippedTabletUid)=%d, want %d", got, want)
}
if got, want := hint.GetSkippedTabletUid()[0].GetTabletUid(), uint64(3); got != want {
t.Fatalf("skippedTabletUid[0]=%d, want %d", got, want)
}
}
func TestKeyRangeCache_FillRoutingHintReturnsNilOnCacheMiss(t *testing.T) {
cache := newKeyRangeCache(newPassthroughChannelEndpointCache())
endpoint := cache.fillRoutingHintWithCooldownTracker(
context.Background(),
false,
rangeModeCoveringSplit,
&sppb.DirectedReadOptions{},
&sppb.RoutingHint{Key: []byte("missing")},
nil,
)
if endpoint != nil {
t.Fatalf("expected nil endpoint on cache miss, got %#v", endpoint)
}
}
func TestKeyRangeCache_FillRoutingHintCreatesMissingEndpointOutsideGroupLock(t *testing.T) {
endpointCache := newTestEndpointCache()
cache := newKeyRangeCache(endpointCache)
cache.addRanges(&sppb.CacheUpdate{
Range: []*sppb.Range{
{
StartKey: []byte("a"),
LimitKey: []byte("z"),
GroupUid: 5,
SplitId: 1,
Generation: []byte("1"),
},
},
Group: []*sppb.Group{
{
GroupUid: 5,
Generation: []byte("1"),
LeaderIndex: 0,
Tablets: []*sppb.Tablet{
{
TabletUid: 1,
ServerAddress: "server-missing",
Incarnation: []byte("1"),
Distance: 0,
},
},
},
},
})
state := cache.loadState()
targetRange := cache.findRangeInState(state, []byte("a"), nil, rangeModeCoveringSplit, cache.loadRoutingConfig())
if targetRange == nil {
t.Fatal("expected target range")
}
group := state.findGroup(targetRange.groupUID)
if group == nil {
t.Fatal("expected target group")
}
endpointCache.onGet = func(address string) {
if address != "server-missing" {
return
}
locked := make(chan struct{}, 1)
go func() {
group.mu.Lock()
group.mu.Unlock()
locked <- struct{}{}
}()
select {
case <-locked:
case <-time.After(100 * time.Millisecond):
t.Fatal("endpoint creation happened while group lock was held")
}
}
hint := &sppb.RoutingHint{Key: []byte("a")}
endpoint := cache.fillRoutingHint(context.Background(), false, rangeModeCoveringSplit, &sppb.DirectedReadOptions{}, hint)
if endpoint == nil || endpoint.Address() != "server-missing" {
t.Fatalf("expected warmed endpoint server-missing, got %#v", endpoint)
}
}
func TestKeyRangeCache_ShrinkTo(t *testing.T) {
cache := newKeyRangeCache(newPassthroughChannelEndpointCache())
const numRanges = 40
for i := 0; i < numRanges; i++ {
startKey := []byte(fmt.Sprintf("%04d", i))
limitKey := []byte(fmt.Sprintf("%04d", i+1))
cache.addRanges(&sppb.CacheUpdate{
Range: []*sppb.Range{
{
StartKey: startKey,
LimitKey: limitKey,
GroupUid: uint64(i + 1),
SplitId: uint64(i + 1),
Generation: []byte("1"),
},
},
Group: []*sppb.Group{
{
GroupUid: uint64(i + 1),
Generation: []byte("1"),
LeaderIndex: 0,
Tablets: []*sppb.Tablet{
{
TabletUid: uint64(i + 1),
ServerAddress: fmt.Sprintf("server-%d", i),
Incarnation: []byte("1"),
Distance: 0,
},
},
},
},
})
}
if got := cache.size(); got != numRanges {
t.Fatalf("cache.size()=%d, want %d", got, numRanges)
}
if got := countCacheHits(cache, numRanges); got != numRanges {
t.Fatalf("countCacheHits=%d, want %d", got, numRanges)
}
cache.shrinkTo(30)
if got := cache.size(); got != 30 {
t.Fatalf("cache.size()=%d, want 30", got)
}
if got := countCacheHits(cache, numRanges); got != 30 {
t.Fatalf("countCacheHits=%d, want 30", got)
}
cache.shrinkTo(5)
if got := cache.size(); got != 5 {
t.Fatalf("cache.size()=%d, want 5", got)
}
if got := countCacheHits(cache, numRanges); got != 5 {
t.Fatalf("countCacheHits=%d, want 5", got)
}
cache.shrinkTo(0)
if got := cache.size(); got != 0 {
t.Fatalf("cache.size()=%d, want 0", got)
}
if got := countCacheHits(cache, numRanges); got != 0 {
t.Fatalf("countCacheHits=%d, want 0", got)
}
}
func TestKeyRangeCache_GetActiveAddresses(t *testing.T) {
cache := newKeyRangeCache(newPassthroughChannelEndpointCache())
cache.addRanges(&sppb.CacheUpdate{
Range: []*sppb.Range{
{
StartKey: []byte("a"),
LimitKey: []byte("m"),
GroupUid: 1,
SplitId: 1,
Generation: []byte("1"),
},
{
StartKey: []byte("m"),
LimitKey: []byte("z"),
GroupUid: 2,
SplitId: 2,
Generation: []byte("1"),
},
},
Group: []*sppb.Group{
{
GroupUid: 1,
Generation: []byte("1"),
Tablets: []*sppb.Tablet{
{
TabletUid: 1,
ServerAddress: "server-a",
Incarnation: []byte("1"),
},
{
TabletUid: 2,
ServerAddress: "server-b",
Incarnation: []byte("1"),
},
},
},
{
GroupUid: 2,
Generation: []byte("1"),
Tablets: []*sppb.Tablet{
{
TabletUid: 3,
ServerAddress: "server-b",
Incarnation: []byte("1"),
},
{
TabletUid: 4,
Incarnation: []byte("1"),
},
},
},
},
})
addresses := activeAddressesForTest(cache)
if len(addresses) != 2 {
t.Fatalf("expected 2 active addresses, got %d", len(addresses))
}
if _, ok := addresses["server-a"]; !ok {
t.Fatal("expected server-a to be active")
}
if _, ok := addresses["server-b"]; !ok {
t.Fatal("expected server-b to be active")
}
}
func activeAddressesForTest(cache *keyRangeCache) map[string]struct{} {
state := cache.loadState()
addresses := make(map[string]struct{})
for _, shard := range state.groupShards {
for _, group := range shard {
group.mu.Lock()
for _, tablet := range group.tablets {
if tablet.serverAddress == "" {
continue
}
addresses[tablet.serverAddress] = struct{}{}
}
group.mu.Unlock()
}
}
return addresses
}
func countCacheHits(cache *keyRangeCache, numRanges int) int {
hits := 0
for i := 0; i < numRanges; i++ {
key := []byte(fmt.Sprintf("%04d", i))
hint := &sppb.RoutingHint{Key: key}
if endpoint := cache.fillRoutingHint(context.Background(), false, rangeModeCoveringSplit, &sppb.DirectedReadOptions{}, hint); endpoint != nil {
hits++
}
}
return hits
}