blob: 4e586ada788a7d11666b1c924cde6b43f49be738 [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"
"errors"
"fmt"
"net"
"sync"
"sync/atomic"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
)
// closableSpannerClient is a minimal spannerClient that tracks Close calls.
// It uses mockSpannerClient from location_aware_client_test.go as the base.
type closableSpannerClient struct {
mockSpannerClient
closed atomic.Bool
}
func (c *closableSpannerClient) Close() error {
c.closed.Store(true)
return nil
}
func newReadyTestConn(t *testing.T) (*grpc.ClientConn, func()) {
t.Helper()
listener := bufconn.Listen(1024 * 1024)
server := grpc.NewServer()
done := make(chan struct{})
go func() {
defer close(done)
_ = server.Serve(listener)
}()
dialer := func(context.Context, string) (net.Conn, error) {
return listener.Dial()
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := grpc.DialContext(
ctx,
"passthrough:///bufconn",
grpc.WithContextDialer(dialer),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("DialContext() failed: %v", err)
}
conn.Connect()
waitForConnState(t, conn, connectivity.Ready)
cleanup := func() {
_ = conn.Close()
server.Stop()
<-done
}
return conn, cleanup
}
func newTransientFailureTestConn(t *testing.T) (*grpc.ClientConn, func()) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := grpc.DialContext(
ctx,
"passthrough:///transient-failure",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return nil, errors.New("dial failed")
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("DialContext() failed: %v", err)
}
conn.Connect()
waitForConnState(t, conn, connectivity.TransientFailure)
return conn, func() { _ = conn.Close() }
}
func waitForConnState(t *testing.T, conn *grpc.ClientConn, target connectivity.State) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for {
state := conn.GetState()
if state == target {
return
}
if !conn.WaitForStateChange(ctx, state) {
t.Fatalf("timed out waiting for state %v, last state %v", target, state)
}
}
}
func TestEndpointClientCache_GetOrCreate(t *testing.T) {
conn, cleanup := newReadyTestConn(t)
defer cleanup()
createCount := 0
factory := func(ctx context.Context, address string) (spannerClient, error) {
createCount++
return &closableSpannerClient{mockSpannerClient: mockSpannerClient{conn: conn}}, nil
}
cache := newEndpointClientCache(factory)
// First Get should create.
ep1 := cache.Get(context.Background(), "addr1")
if ep1 == nil {
t.Fatal("expected non-nil endpoint")
}
if ep1.Address() != "addr1" {
t.Fatalf("expected address addr1, got %s", ep1.Address())
}
if !ep1.IsHealthy() {
t.Fatal("expected endpoint to be healthy")
}
if createCount != 1 {
t.Fatalf("expected 1 creation, got %d", createCount)
}
// Second Get for same address should hit cache.
ep2 := cache.Get(context.Background(), "addr1")
if ep2 != ep1 {
t.Fatal("expected same endpoint from cache")
}
if createCount != 1 {
t.Fatalf("expected still 1 creation, got %d", createCount)
}
// Different address should create new.
ep3 := cache.Get(context.Background(), "addr2")
if ep3 == nil {
t.Fatal("expected non-nil endpoint for addr2")
}
if createCount != 2 {
t.Fatalf("expected 2 creations, got %d", createCount)
}
}
func TestEndpointClientCache_ClientFor(t *testing.T) {
client := &closableSpannerClient{}
factory := func(ctx context.Context, address string) (spannerClient, error) {
return client, nil
}
cache := newEndpointClientCache(factory)
ep := cache.Get(context.Background(), "addr1")
resolved := cache.ClientFor(ep)
if resolved == nil {
t.Fatal("expected non-nil client from ClientFor")
}
// ClientFor with nil should return nil.
if cache.ClientFor(nil) != nil {
t.Fatal("expected nil from ClientFor(nil)")
}
// ClientFor with non-grpcChannelEndpoint should return nil.
passthrough := &passthroughChannelEndpoint{address: "foo"}
if cache.ClientFor(passthrough) != nil {
t.Fatal("expected nil from ClientFor(passthroughEndpoint)")
}
}
func TestEndpointClientCache_Close(t *testing.T) {
clients := make([]*closableSpannerClient, 0)
factory := func(ctx context.Context, address string) (spannerClient, error) {
c := &closableSpannerClient{}
clients = append(clients, c)
return c, nil
}
cache := newEndpointClientCache(factory)
cache.Get(context.Background(), "addr1")
cache.Get(context.Background(), "addr2")
if len(clients) != 2 {
t.Fatalf("expected 2 clients, got %d", len(clients))
}
if err := cache.Close(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i, c := range clients {
if !c.closed.Load() {
t.Fatalf("client %d was not closed", i)
}
}
// Cache should be empty after close.
cache.mu.RLock()
remaining := len(cache.endpoints)
cache.mu.RUnlock()
if remaining != 0 {
t.Fatalf("expected 0 endpoints after close, got %d", remaining)
}
}
func TestEndpointClientCache_FactoryError(t *testing.T) {
factory := func(ctx context.Context, address string) (spannerClient, error) {
return nil, fmt.Errorf("connection failed")
}
cache := newEndpointClientCache(factory)
ep := cache.Get(context.Background(), "addr1")
if ep != nil {
t.Fatal("expected nil endpoint when factory fails")
}
}
func TestEndpointClientCache_UnhealthyEndpoint(t *testing.T) {
conn, cleanup := newTransientFailureTestConn(t)
defer cleanup()
factory := func(ctx context.Context, address string) (spannerClient, error) {
return &closableSpannerClient{mockSpannerClient: mockSpannerClient{conn: conn}}, nil
}
cache := newEndpointClientCache(factory)
ep := cache.Get(context.Background(), "addr1")
if ep.IsHealthy() {
t.Fatal("expected endpoint to be unhealthy in transient failure")
}
if !ep.IsTransientFailure() {
t.Fatal("expected endpoint to report transient failure")
}
}
func TestEndpointClientCache_GetPassesContextToFactory(t *testing.T) {
type contextKey string
const key contextKey = "request"
ctx := context.WithValue(context.Background(), key, "ctx-value")
factory := func(factoryCtx context.Context, address string) (spannerClient, error) {
if got := factoryCtx.Value(key); got != "ctx-value" {
t.Fatalf("expected context value to propagate, got %v", got)
}
return &closableSpannerClient{}, nil
}
cache := newEndpointClientCache(factory)
if ep := cache.Get(ctx, "addr1"); ep == nil {
t.Fatal("expected endpoint")
}
}
func TestEndpointClientCache_GetSingleFlightPerAddress(t *testing.T) {
var createCount atomic.Int32
started := make(chan struct{})
release := make(chan struct{})
factory := func(ctx context.Context, address string) (spannerClient, error) {
if createCount.Add(1) == 1 {
close(started)
}
<-release
return &closableSpannerClient{}, nil
}
cache := newEndpointClientCache(factory)
results := make(chan channelEndpoint, 2)
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
results <- cache.Get(context.Background(), "addr1")
}()
}
<-started
close(release)
wg.Wait()
close(results)
if got := createCount.Load(); got != 1 {
t.Fatalf("expected one factory call, got %d", got)
}
var first channelEndpoint
for ep := range results {
if ep == nil {
t.Fatal("expected non-nil endpoint")
}
if first == nil {
first = ep
continue
}
if ep != first {
t.Fatal("expected both callers to observe the same endpoint")
}
}
}
func TestEndpointClientCache_GetIfPresent(t *testing.T) {
createCount := 0
factory := func(ctx context.Context, address string) (spannerClient, error) {
createCount++
return &closableSpannerClient{}, nil
}
cache := newEndpointClientCache(factory)
if got := cache.GetIfPresent("addr1"); got != nil {
t.Fatalf("GetIfPresent(addr1) = %#v, want nil", got)
}
if createCount != 0 {
t.Fatalf("expected GetIfPresent not to create clients, got %d creations", createCount)
}
created := cache.Get(context.Background(), "addr1")
if created == nil {
t.Fatal("expected endpoint creation")
}
if got := cache.GetIfPresent("addr1"); got != created {
t.Fatalf("GetIfPresent(addr1) = %#v, want %#v", got, created)
}
}
func TestEndpointClientCache_Evict(t *testing.T) {
client := &closableSpannerClient{}
cache := newEndpointClientCache(func(context.Context, string) (spannerClient, error) {
return client, nil
})
created := cache.Get(context.Background(), "addr1")
if created == nil {
t.Fatal("expected endpoint creation")
}
cache.Evict("addr1")
if !client.closed.Load() {
t.Fatal("expected evicted endpoint client to be closed")
}
if got := cache.GetIfPresent("addr1"); got != nil {
t.Fatalf("GetIfPresent(addr1) after Evict = %#v, want nil", got)
}
}
func TestEndpointClientCache_EvictInFlightCreation(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
client := &closableSpannerClient{}
cache := newEndpointClientCache(func(context.Context, string) (spannerClient, error) {
close(started)
<-release
return client, nil
})
result := make(chan channelEndpoint, 1)
go func() {
result <- cache.Get(context.Background(), "addr1")
}()
<-started
cache.Evict("addr1")
close(release)
if got := <-result; got != nil {
t.Fatalf("Get(addr1) after inflight Evict = %#v, want nil", got)
}
if !client.closed.Load() {
t.Fatal("expected evicted in-flight client to be closed")
}
}
func TestEndpointClientCache_DefaultChannel(t *testing.T) {
cache := newEndpointClientCache(func(context.Context, string) (spannerClient, error) {
return &closableSpannerClient{}, nil
})
defaultChannel := cache.DefaultChannel()
if defaultChannel == nil {
t.Fatal("expected non-nil default channel")
}
if defaultChannel.Address() != "" {
t.Fatalf("default channel address = %q, want empty sentinel", defaultChannel.Address())
}
cache.Evict(defaultChannel.Address())
if got := cache.DefaultChannel(); got != defaultChannel {
t.Fatalf("default channel changed after Evict: got %#v, want %#v", got, defaultChannel)
}
}
func TestEndpointClientCache_DefaultChannelUsesConfiguredAddress(t *testing.T) {
cache := newEndpointClientCacheWithDefaultAddress(
func(context.Context, string) (spannerClient, error) {
return &closableSpannerClient{}, nil
},
"spanner.googleapis.com:443",
)
defaultChannel := cache.DefaultChannel()
if defaultChannel == nil {
t.Fatal("expected non-nil default channel")
}
if got := defaultChannel.Address(); got != "spanner.googleapis.com:443" {
t.Fatalf("default channel address = %q, want %q", got, "spanner.googleapis.com:443")
}
}
func TestEndpointClientCache_GetReturnsDefaultChannelForConfiguredAddress(t *testing.T) {
createCount := 0
cache := newEndpointClientCacheWithDefaultAddress(
func(context.Context, string) (spannerClient, error) {
createCount++
t.Fatal("factory should not be called for the configured default endpoint")
return nil, nil
},
"spanner.googleapis.com:443",
)
defaultChannel := cache.DefaultChannel()
if defaultChannel == nil {
t.Fatal("expected non-nil default channel")
}
if got := cache.Get(context.Background(), "spanner.googleapis.com:443"); got != defaultChannel {
t.Fatalf("Get(default endpoint) = %#v, want %#v", got, defaultChannel)
}
if createCount != 0 {
t.Fatalf("factory createCount = %d, want 0", createCount)
}
}
func TestGRPCChannelEndpoint_GetConn(t *testing.T) {
conn, cleanup := newReadyTestConn(t)
defer cleanup()
endpoint := &grpcChannelEndpoint{
address: "addr1",
conn: conn,
}
if got := endpoint.GetConn(); got != conn {
t.Fatalf("GetConn() = %#v, want %#v", got, conn)
}
}