Skip to content

Commit ee4be95

Browse files
committed
storage/gcp/antispam: add optional SpannerClient to AntispamOpts
Allows callers to provide an existing Spanner client to NewAntispam instead of it always creating its own, mirroring Config.SpannerClient on the GCP log storage. This lets multi-tenant deployments share a single client (and its session pool) across the log storage and any number of antispam instances in the same database. An injected client must be connected to the database named by the spannerDB parameter; this is validated at construction since a mismatched client would otherwise silently disable deduplication (lookups treat NotFound as a cache miss). An injected client is also used to apply the initial schema mutations; when no client is provided, table preparation keeps using its own temporary client and behavior is unchanged.
1 parent 384d601 commit ee4be95

2 files changed

Lines changed: 77 additions & 10 deletions

File tree

storage/gcp/antispam/gcp.go

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ type AntispamOpts struct {
8484
// underscores, and prepending a letter if the origin doesn't start with one), to
8585
// make it easy to associate tables with specific logs.
8686
SpannerTablePrefix string
87+
88+
// SpannerClient will be used to interact with Spanner. If unset, Tessera will create one.
89+
// If set, it must be connected to the database identified by the spannerDB parameter to
90+
// NewAntispam, and it will never be closed by Tessera.
91+
SpannerClient *spanner.Client
8792
}
8893

8994
// tablePrefixRE matches valid values for a Spanner table prefix: empty, or a leading
@@ -107,11 +112,17 @@ func NewAntispam(ctx context.Context, spannerDB string, opts AntispamOpts) (*Ant
107112
if !tablePrefixRE.MatchString(opts.SpannerTablePrefix) {
108113
return nil, fmt.Errorf("invalid SpannerTablePrefix %q: must start with a letter, contain only letters, digits, or underscores, and be at most 64 characters long", opts.SpannerTablePrefix)
109114
}
115+
if opts.SpannerClient != nil {
116+
if got := opts.SpannerClient.DatabaseName(); got != spannerDB {
117+
return nil, fmt.Errorf("provided SpannerClient is connected to %q, want %q", got, spannerDB)
118+
}
119+
}
110120
table := func(t string) string {
111121
return opts.SpannerTablePrefix + t
112122
}
123+
113124
if err := createAndPrepareTables(
114-
ctx, spannerDB,
125+
ctx, spannerDB, opts.SpannerClient,
115126
[]string{
116127
fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (id INT64 NOT NULL, nextIdx INT64 NOT NULL) PRIMARY KEY (id)", table("FollowCoord")),
117128
fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (h BYTES(32) NOT NULL, idx INT64 NOT NULL) PRIMARY KEY (h)", table("IDSeq")),
@@ -123,9 +134,13 @@ func NewAntispam(ctx context.Context, spannerDB string, opts AntispamOpts) (*Ant
123134
return nil, fmt.Errorf("failed to create tables: %v", err)
124135
}
125136

126-
db, err := spanner.NewClient(ctx, spannerDB)
127-
if err != nil {
128-
return nil, fmt.Errorf("failed to connect to Spanner: %v", err)
137+
db := opts.SpannerClient
138+
if db == nil {
139+
var err error
140+
db, err = spanner.NewClient(ctx, spannerDB)
141+
if err != nil {
142+
return nil, fmt.Errorf("failed to connect to Spanner: %v", err)
143+
}
129144
}
130145

131146
r := &AntispamStorage{
@@ -477,7 +492,9 @@ func (f *follower) EntriesProcessed(ctx context.Context) (uint64, error) {
477492
// This is intended to be used to create and initialise Spanner instances on first use.
478493
// DDL should likely be of the form "CREATE TABLE IF NOT EXISTS".
479494
// Mutation groups should likey be one or more spanner.Insert operations - AlreadyExists errors will be silently ignored.
480-
func createAndPrepareTables(ctx context.Context, spannerDB string, ddl []string, mutations [][]*spanner.Mutation) error {
495+
// If dbPool is non-nil it is used to apply the mutations (and is not closed); otherwise a
496+
// temporary client is created for the duration of this call.
497+
func createAndPrepareTables(ctx context.Context, spannerDB string, dbPool *spanner.Client, ddl []string, mutations [][]*spanner.Mutation) error {
481498
adminClient, err := database.NewDatabaseAdminClient(ctx)
482499
if err != nil {
483500
return err
@@ -499,11 +516,13 @@ func createAndPrepareTables(ctx context.Context, spannerDB string, ddl []string,
499516
return err
500517
}
501518

502-
dbPool, err := spanner.NewClient(ctx, spannerDB)
503-
if err != nil {
504-
return fmt.Errorf("failed to connect to Spanner: %v", err)
519+
if dbPool == nil {
520+
dbPool, err = spanner.NewClient(ctx, spannerDB)
521+
if err != nil {
522+
return fmt.Errorf("failed to connect to Spanner: %v", err)
523+
}
524+
defer dbPool.Close()
505525
}
506-
defer dbPool.Close()
507526

508527
// Set default values for a newly initialised schema using passed in mutation groups.
509528
// Note that this will only succeed if no row exists, so there's no danger of "resetting" an existing log.

storage/gcp/antispam/gcp_test.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424

2525
"log/slog"
2626

27+
"cloud.google.com/go/spanner"
2728
"cloud.google.com/go/spanner/spannertest"
2829
"github.com/transparency-dev/tessera"
2930
"github.com/transparency-dev/tessera/api"
@@ -39,6 +40,7 @@ func TestAntispamStorage(t *testing.T) {
3940
for _, test := range []struct {
4041
name string
4142
opts AntispamOpts
43+
sharedClient bool
4244
logEntries [][]byte
4345
lookupEntries []testLookup
4446
}{
@@ -62,6 +64,25 @@ func TestAntispamStorage(t *testing.T) {
6264
},
6365
},
6466
},
67+
{
68+
name: "roundtrip with shared client",
69+
opts: AntispamOpts{SpannerTablePrefix: "Shared1_"},
70+
sharedClient: true,
71+
logEntries: [][]byte{
72+
[]byte("one"),
73+
[]byte("two"),
74+
},
75+
lookupEntries: []testLookup{
76+
{
77+
entryHash: testIDHash([]byte("one")),
78+
}, {
79+
entryHash: testIDHash([]byte("two")),
80+
}, {
81+
entryHash: testIDHash([]byte("nowhere to be found")),
82+
wantNotFound: true,
83+
},
84+
},
85+
},
6586
{
6687
name: "roundtrip with table prefix",
6788
opts: AntispamOpts{SpannerTablePrefix: "Tenant1_"},
@@ -84,7 +105,19 @@ func TestAntispamStorage(t *testing.T) {
84105
t.Run(test.name, func(t *testing.T) {
85106
closeDB := newSpannerDB(t)
86107
defer closeDB()
87-
as, err := NewAntispam(t.Context(), "projects/p/instances/i/databases/d", test.opts)
108+
const spannerDB = "projects/p/instances/i/databases/d"
109+
if test.sharedClient {
110+
c, err := spanner.NewClient(t.Context(), spannerDB)
111+
if err != nil {
112+
t.Fatalf("spanner.NewClient: %v", err)
113+
}
114+
// As documented on AntispamOpts.SpannerClient, the caller retains
115+
// ownership of the client's lifecycle. Cleanup runs after this
116+
// function's defers have shut down the follower.
117+
t.Cleanup(c.Close)
118+
test.opts.SpannerClient = c
119+
}
120+
as, err := NewAntispam(t.Context(), spannerDB, test.opts)
88121
if err != nil {
89122
t.Fatalf("NewAntispam: %v", err)
90123
}
@@ -151,6 +184,21 @@ func TestAntispamStorage(t *testing.T) {
151184
}
152185
}
153186

187+
func TestAntispamSharedClientWrongDatabase(t *testing.T) {
188+
closeDB := newSpannerDB(t)
189+
defer closeDB()
190+
191+
c, err := spanner.NewClient(t.Context(), "projects/p/instances/i/databases/other")
192+
if err != nil {
193+
t.Fatalf("spanner.NewClient: %v", err)
194+
}
195+
defer c.Close()
196+
197+
if _, err := NewAntispam(t.Context(), "projects/p/instances/i/databases/d", AntispamOpts{SpannerClient: c}); err == nil {
198+
t.Error("NewAntispam accepted a SpannerClient connected to a different database, want error")
199+
}
200+
}
201+
154202
func TestAntispamPushbackRecovers(t *testing.T) {
155203
for _, test := range []struct {
156204
name string

0 commit comments

Comments
 (0)