Skip to content

Commit 03c23de

Browse files
committed
add integration test with spanner emulator
1 parent a8ae954 commit 03c23de

5 files changed

Lines changed: 386 additions & 1323 deletions

File tree

.github/workflows/run-tests.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ jobs:
2727
- uses: actions/checkout@v2
2828
- uses: actions/setup-go@v2
2929
with:
30-
go-version: '1.20'
30+
go-version: '1.24'
3131
- run: go version
3232
- run: go vet ./...
33-
- run: go test -v ./...
33+
- run: go test -race -v ./...
3434
env:
3535
TEST_PROJECT_ID: ${{ secrets.TEST_PROJECT_ID }}
3636
TEST_INSTANCE_ID: ${{ secrets.TEST_INSTANCE_ID }}

changestreams/reader.go

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ type Reader struct {
115115
endTimestamp time.Time
116116
heartbeatInterval time.Duration
117117
dialect dialect
118-
states map[string]partitionState
118+
states sync.Map
119119
group *errgroup.Group
120120
mu sync.Mutex
121121
}
@@ -165,7 +165,7 @@ func NewReaderWithConfig(ctx context.Context, projectID, instanceID, databaseID,
165165
endTimestamp: config.EndTimestamp,
166166
heartbeatInterval: heartbeatInterval,
167167
dialect: dialect,
168-
states: make(map[string]partitionState),
168+
states: sync.Map{},
169169
}, nil
170170
}
171171

@@ -174,7 +174,8 @@ func (r *Reader) Close() {
174174
r.client.Close()
175175
}
176176

177-
// Read starts reading the change stream.
177+
// Read starts reading the change stream and calls the function f on every result.
178+
// You can ignore the ChildPartitionsRecords because they are processed here.
178179
//
179180
// If function f returns an error, Read finishes the process and returns the error.
180181
// Once this method is called, reader must not be reused in any other places (i.e. not reentrant).
@@ -281,47 +282,44 @@ func (r *Reader) startRead(ctx context.Context, partitionToken string, startTime
281282
// childStartTimestamp is always later than r.startTimestamp.
282283
childStartTimestamp := childPartitionsRecord.StartTimestamp
283284
for _, childPartition := range childPartitionsRecord.ChildPartitions {
284-
if r.canReadChild(childPartition) {
285-
partition := childPartition
286-
r.group.Go(func() error {
287-
return r.startRead(ctx, partition.Token, childStartTimestamp, f)
288-
})
289-
}
285+
r.group.Go(func() error {
286+
r.waitUntilCanReadChild(childPartition)
287+
return r.startRead(ctx, childPartition.Token, childStartTimestamp, f)
288+
})
290289
}
291290
}
292291

293292
return nil
294293
}
295294

296295
func (r *Reader) markStateReading(partitionToken string) bool {
297-
r.mu.Lock()
298-
defer r.mu.Unlock()
299-
300-
if _, ok := r.states[partitionToken]; ok {
296+
if _, ok := r.states.Load(partitionToken); ok {
301297
// Already started by another parent.
302298
return false
303299
}
304-
r.states[partitionToken] = partitionStateReading
300+
r.states.Store(partitionToken, partitionStateReading)
305301
return true
306302
}
307303

308304
func (r *Reader) markStateFinished(partitionToken string) {
309-
r.mu.Lock()
310-
defer r.mu.Unlock()
311-
312-
r.states[partitionToken] = partitionStateFinished
305+
r.states.Store(partitionToken, partitionStateFinished)
313306
}
314307

315-
func (r *Reader) canReadChild(partition *ChildPartition) bool {
316-
r.mu.Lock()
317-
defer r.mu.Unlock()
318-
319-
for _, parent := range partition.ParentPartitionTokens {
320-
if r.states[parent] != partitionStateFinished {
321-
return false
308+
func (r *Reader) waitUntilCanReadChild(partition *ChildPartition) {
309+
for {
310+
allDone := true
311+
for _, parent := range partition.ParentPartitionTokens {
312+
state, _ := r.states.Load(parent)
313+
if state != partitionStateFinished {
314+
allDone = false
315+
break
316+
}
322317
}
318+
if allDone {
319+
return
320+
}
321+
// ideally sleep a bit or wait on a cond, otherwise this spins forever
323322
}
324-
return true
325323
}
326324

327325
func decodePostgresRow(row *spanner.Row) (*ChangeRecord, error) {
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package changestreams
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"testing"
8+
"time"
9+
10+
"cloud.google.com/go/spanner"
11+
database "cloud.google.com/go/spanner/admin/database/apiv1"
12+
"cloud.google.com/go/spanner/admin/database/apiv1/databasepb"
13+
instancesapi "cloud.google.com/go/spanner/admin/instance/apiv1"
14+
"cloud.google.com/go/spanner/admin/instance/apiv1/instancepb"
15+
"github.com/google/uuid"
16+
"github.com/ory/dockertest/v3"
17+
"github.com/ory/dockertest/v3/docker"
18+
"github.com/stretchr/testify/require"
19+
"golang.org/x/sync/errgroup"
20+
"google.golang.org/api/option"
21+
"google.golang.org/grpc"
22+
"google.golang.org/grpc/credentials/insecure"
23+
)
24+
25+
const (
26+
defaultEmulatorHost = "localhost"
27+
defaultEmulatorPort = "9010"
28+
29+
projectID = "project-id"
30+
instanceID = "instance-id"
31+
tableID = "test_table"
32+
streamID = "test_stream"
33+
)
34+
35+
type SpannerEmulatorContainer struct {
36+
endpoint string
37+
dbname string
38+
dbid string
39+
}
40+
41+
func RunSpannerForTesting(t testing.TB, containerHost, containerPrivatePort string, dialect databasepb.DatabaseDialect) *SpannerEmulatorContainer {
42+
t.Helper()
43+
44+
pool, err := dockertest.NewPool("")
45+
require.NoError(t, err)
46+
47+
resource, err := pool.RunWithOptions(&dockertest.RunOptions{
48+
Name: "spanner-" + uuid.New().String(),
49+
Repository: "gcr.io/cloud-spanner-emulator/emulator",
50+
Tag: "1.5.45",
51+
ExposedPorts: []string{fmt.Sprintf("%s/tcp", containerPrivatePort)},
52+
}, func(config *docker.HostConfig) {
53+
config.AutoRemove = true
54+
config.RestartPolicy = docker.RestartPolicy{Name: "no"}
55+
})
56+
require.NoError(t, err)
57+
58+
t.Cleanup(func() {
59+
require.NoError(t, pool.Purge(resource))
60+
})
61+
62+
containerPublicPort := resource.GetPort(fmt.Sprintf("%s/tcp", containerPrivatePort))
63+
t.Setenv("SPANNER_EMULATOR_HOST", fmt.Sprintf("%s:%s", containerHost, containerPublicPort))
64+
65+
t.Log("using spanner emulator", os.Getenv("SPANNER_EMULATOR_HOST"))
66+
67+
instancesClient, err := instancesapi.NewInstanceAdminClient(t.Context())
68+
require.NoError(t, err)
69+
defer instancesClient.Close()
70+
71+
createInstanceOp, err := instancesClient.CreateInstance(t.Context(), &instancepb.CreateInstanceRequest{
72+
Parent: "projects/" + projectID,
73+
InstanceId: instanceID,
74+
Instance: &instancepb.Instance{
75+
Config: "emulator-config",
76+
DisplayName: "Test Instance",
77+
NodeCount: 3,
78+
},
79+
})
80+
require.NoError(t, err)
81+
82+
spannerInstance, err := createInstanceOp.Wait(t.Context())
83+
require.NoError(t, err)
84+
85+
adminClient, err := database.NewDatabaseAdminClient(t.Context())
86+
require.NoError(t, err)
87+
defer adminClient.Close()
88+
89+
dbID := "database-id"
90+
91+
op, err := adminClient.CreateDatabase(t.Context(), &databasepb.CreateDatabaseRequest{
92+
Parent: spannerInstance.Name,
93+
CreateStatement: "CREATE DATABASE `" + dbID + "`",
94+
ExtraStatements: []string{
95+
fmt.Sprintf(`CREATE TABLE %s (
96+
id INT64 NOT NULL,
97+
name STRING(100),
98+
value INT64
99+
) PRIMARY KEY (id)`, tableID),
100+
fmt.Sprintf("CREATE CHANGE STREAM %s FOR %s", streamID, tableID),
101+
},
102+
})
103+
require.NoError(t, err)
104+
105+
db, err := op.Wait(t.Context())
106+
require.NoError(t, err)
107+
108+
t.Log("created database with change stream")
109+
110+
endpoint := fmt.Sprintf("%s:%s", defaultEmulatorHost, containerPublicPort)
111+
112+
return &SpannerEmulatorContainer{endpoint: endpoint, dbname: db.Name, dbid: dbID}
113+
}
114+
115+
// TestReaderWithEmulator tests that the reader correctly reads three rows written in a single transaction
116+
// and that all three changes share the same commit timestamp and server transaction ID.
117+
func TestReaderWithEmulator(t *testing.T) {
118+
if testing.Short() {
119+
t.Skip("skipping integration test in short mode")
120+
}
121+
122+
dialects := []databasepb.DatabaseDialect{
123+
databasepb.DatabaseDialect_GOOGLE_STANDARD_SQL,
124+
//databasepb.DatabaseDialect_POSTGRESQL,
125+
}
126+
127+
for _, dialect := range dialects {
128+
t.Run(dialect.String(), func(t *testing.T) {
129+
spannerEmulatorContainer := RunSpannerForTesting(t, defaultEmulatorHost, defaultEmulatorPort, dialect)
130+
131+
opts := []option.ClientOption{
132+
option.WithEndpoint(spannerEmulatorContainer.endpoint),
133+
option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())),
134+
option.WithoutAuthentication(),
135+
}
136+
137+
dbPath := fmt.Sprintf("projects/%s/instances/%s/databases/%s", projectID, instanceID, spannerEmulatorContainer.dbid)
138+
client, err := spanner.NewClient(t.Context(), dbPath, opts...)
139+
require.NoError(t, err)
140+
defer client.Close()
141+
142+
reader, err := NewReaderWithConfig(t.Context(), projectID, instanceID, spannerEmulatorContainer.dbid, streamID, Config{
143+
SpannerClientOptions: opts,
144+
HeartbeatInterval: 1 * time.Second,
145+
})
146+
require.NoError(t, err)
147+
defer reader.Close()
148+
149+
t.Log("Created changestream reader")
150+
151+
// Channel to collect data change records
152+
recordsChan := make(chan *DataChangeRecord, 3)
153+
154+
// Start reader in parallel
155+
readerCtx, readerCancel := context.WithCancel(context.Background())
156+
defer readerCancel()
157+
158+
var wg errgroup.Group
159+
wg.Go(func() error {
160+
return reader.Read(readerCtx, func(result *ReadResult) error {
161+
for _, changeRecord := range result.ChangeRecords {
162+
for _, dataChange := range changeRecord.DataChangeRecords {
163+
recordsChan <- dataChange
164+
t.Logf("Received data change record: table=%s, mod_type=%s, commit_ts=%v, txn_id=%s, is_last=%v, num_records=%d, transaction_tag=%s",
165+
dataChange.TableName, dataChange.ModType, dataChange.CommitTimestamp,
166+
dataChange.ServerTransactionID, dataChange.IsLastRecordInTransactionInPartition,
167+
dataChange.NumberOfRecordsInTransaction, dataChange.TransactionTag)
168+
}
169+
}
170+
return nil
171+
})
172+
})
173+
174+
commitTimestamp, err := client.ReadWriteTransactionWithOptions(t.Context(), func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
175+
mutations := []*spanner.Mutation{
176+
spanner.Insert(tableID, []string{"id", "name", "value"}, []interface{}{1, "row1", 100}),
177+
spanner.Insert(tableID, []string{"id", "name", "value"}, []interface{}{2, "row2", 200}),
178+
spanner.Insert(tableID, []string{"id", "name", "value"}, []interface{}{3, "row3", 300}),
179+
}
180+
return txn.BufferWrite(mutations)
181+
}, spanner.TransactionOptions{TransactionTag: uuid.New().String()})
182+
require.NoError(t, err)
183+
t.Logf("Wrote 3 rows in transaction with commit timestamp: %v", commitTimestamp)
184+
185+
commitTimestamp, err = client.ReadWriteTransactionWithOptions(t.Context(), func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
186+
mutations := []*spanner.Mutation{
187+
spanner.Update(tableID, []string{"id", "value"}, []interface{}{1, 1_000}),
188+
spanner.Update(tableID, []string{"id", "value"}, []interface{}{2, 1_000}),
189+
spanner.Delete(tableID, spanner.Key{"3"}),
190+
}
191+
return txn.BufferWrite(mutations)
192+
}, spanner.TransactionOptions{TransactionTag: uuid.New().String()})
193+
require.NoError(t, err)
194+
t.Logf("Updated 3 rows in transaction with commit timestamp: %v", commitTimestamp)
195+
196+
var records []*DataChangeRecord
197+
collectTimeout := time.After(15 * time.Second)
198+
collecting := true
199+
200+
for collecting {
201+
select {
202+
case record := <-recordsChan:
203+
records = append(records, record)
204+
case <-collectTimeout:
205+
collecting = false
206+
}
207+
}
208+
209+
readerCancel()
210+
err = wg.Wait()
211+
require.ErrorContains(t, err, "context canceled")
212+
close(recordsChan)
213+
214+
// one record for the 3 inserts, one for the updates, one for the deletes
215+
require.Len(t, records, 3)
216+
217+
type assertStruct struct {
218+
modType string
219+
modNumber int
220+
}
221+
222+
var asserts = map[*DataChangeRecord]assertStruct{
223+
records[0]: {modType: "INSERT", modNumber: 3},
224+
records[1]: {modType: "UPDATE", modNumber: 2},
225+
records[2]: {modType: "DELETE", modNumber: 1},
226+
}
227+
228+
for _, record := range records {
229+
require.Equal(t, asserts[record].modNumber, len(record.Mods))
230+
require.Equal(t, asserts[record].modType, record.ModType)
231+
232+
require.NotEmpty(t, record.TransactionTag)
233+
}
234+
})
235+
}
236+
}

0 commit comments

Comments
 (0)