eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// 🤖 SPIKE tests (issue #550 Part 4 v2). Live S3 tests are gated on
2// LCHAT_TEST_S3_BUCKET; without it everything skips so `go test ./...`
3// stays green on CI and dev machines with no AWS credentials.
4package activator
5
6import (
7 "context"
8 "crypto/rand"
9 "database/sql"
10 "encoding/hex"
11 "errors"
12 "fmt"
13 "os"
14 "path/filepath"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/aws/aws-sdk-go-v2/aws"
20 awsconfig "github.com/aws/aws-sdk-go-v2/config"
21 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
22 s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
23 "github.com/benbjohnson/litestream"
24
25 _ "modernc.org/sqlite"
26)
27
28func liveConfig(t *testing.T) (bucket, prefix, region string) {
29 t.Helper()
30 bucket = os.Getenv("LCHAT_TEST_S3_BUCKET")
31 if bucket == "" {
32 t.Skip("LCHAT_TEST_S3_BUCKET not set; skipping live S3 spike test")
33 }
34 region = os.Getenv("LCHAT_TEST_S3_REGION")
35 if region == "" {
36 region = "eu-west-1"
37 }
38 b := make([]byte, 4)
39 _, _ = rand.Read(b)
40 prefix = fmt.Sprintf("spike/%d-%s", time.Now().Unix(), hex.EncodeToString(b))
41 t.Cleanup(func() { cleanupPrefix(t, bucket, prefix, region) })
42 return bucket, prefix, region
43}
44
45// seedDB creates a WAL-mode SQLite database via modernc.org/sqlite (the
46// driver this repo already uses — no cgo anywhere in the test).
47func seedDB(t *testing.T, path string) {
48 t.Helper()
49 db, err := sql.Open("sqlite", path)
50 if err != nil {
51 t.Fatalf("open sqlite: %v", err)
52 }
53 defer db.Close()
54 if _, err := db.Exec(`PRAGMA journal_mode=WAL`); err != nil {
55 t.Fatalf("wal mode: %v", err)
56 }
57 if _, err := db.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY, body TEXT)`); err != nil {
58 t.Fatalf("create table: %v", err)
59 }
60 for i := 0; i < 50; i++ {
61 if _, err := db.Exec(`INSERT INTO t (body) VALUES (?)`, fmt.Sprintf("row-%d", i)); err != nil {
62 t.Fatalf("insert: %v", err)
63 }
64 }
65}
66
67func s3Client(t *testing.T, region string) *awss3.Client {
68 t.Helper()
69 cfg, err := awsconfig.LoadDefaultConfig(context.Background(), awsconfig.WithRegion(region))
70 if err != nil {
71 t.Fatalf("aws config: %v", err)
72 }
73 return awss3.NewFromConfig(cfg)
74}
75
76func listKeys(t *testing.T, bucket, prefix, region string) []string {
77 t.Helper()
78 client := s3Client(t, region)
79 var keys []string
80 p := awss3.NewListObjectsV2Paginator(client, &awss3.ListObjectsV2Input{
81 Bucket: aws.String(bucket),
82 Prefix: aws.String(prefix),
83 })
84 for p.HasMorePages() {
85 out, err := p.NextPage(context.Background())
86 if err != nil {
87 t.Fatalf("list objects: %v", err)
88 }
89 for _, o := range out.Contents {
90 keys = append(keys, *o.Key)
91 }
92 }
93 return keys
94}
95
96// cleanupPrefix deletes every object AND version under the test prefix so
97// spike runs leave nothing behind in the bucket.
98func cleanupPrefix(t *testing.T, bucket, prefix, region string) {
99 t.Helper()
100 client := s3Client(t, region)
101 ctx := context.Background()
102 p := awss3.NewListObjectVersionsPaginator(client, &awss3.ListObjectVersionsInput{
103 Bucket: aws.String(bucket),
104 Prefix: aws.String(prefix),
105 })
106 for p.HasMorePages() {
107 out, err := p.NextPage(ctx)
108 if err != nil {
109 t.Logf("cleanup list versions: %v", err)
110 return
111 }
112 var objs []s3types.ObjectIdentifier
113 for _, v := range out.Versions {
114 objs = append(objs, s3types.ObjectIdentifier{Key: v.Key, VersionId: v.VersionId})
115 }
116 for _, m := range out.DeleteMarkers {
117 objs = append(objs, s3types.ObjectIdentifier{Key: m.Key, VersionId: m.VersionId})
118 }
119 if len(objs) == 0 {
120 continue
121 }
122 if _, err := client.DeleteObjects(ctx, &awss3.DeleteObjectsInput{
123 Bucket: aws.String(bucket),
124 Delete: &s3types.Delete{Objects: objs},
125 }); err != nil {
126 t.Logf("cleanup delete: %v", err)
127 }
128 }
129}
130
131// TestReplicateWithLease_ReplicatesToS3 proves Q1+Q2: the embedded library
132// (pure Go, modernc driver) replicates a real database to S3 under a lease.
133func TestReplicateWithLease_ReplicatesToS3(t *testing.T) {
134 bucket, prefix, region := liveConfig(t)
135 ctx := context.Background()
136
137 dbPath := filepath.Join(t.TempDir(), "spike.db")
138 seedDB(t, dbPath)
139
140 r, err := ReplicateWithLease(ctx, dbPath, bucket, prefix, region)
141 if err != nil {
142 t.Fatalf("ReplicateWithLease: %v", err)
143 }
144 defer r.Close(ctx)
145
146 if err := r.SyncAndWait(ctx); err != nil {
147 t.Fatalf("SyncAndWait: %v", err)
148 }
149
150 keys := listKeys(t, bucket, prefix, region)
151 t.Logf("objects under %s: %v", prefix, keys)
152 var haveLock, haveLTX bool
153 for _, k := range keys {
154 if strings.HasSuffix(k, "lock.json") {
155 haveLock = true
156 }
157 if strings.Contains(k, ".ltx") {
158 haveLTX = true
159 }
160 }
161 if !haveLock {
162 t.Errorf("expected %s/lock.json in bucket, keys=%v", prefix, keys)
163 }
164 if !haveLTX {
165 t.Errorf("expected replicated ltx data under %s, keys=%v", prefix, keys)
166 }
167
168 if err := r.Close(ctx); err != nil {
169 t.Fatalf("Close: %v", err)
170 }
171}
172
173// TestReplicateWithLease_MutualExclusion proves Q3: a second claimant on the
174// same replica path fails LOUDLY at lease acquisition with a typed error —
175// unlike the CLI, which silently absorbs the second writer.
176func TestReplicateWithLease_MutualExclusion(t *testing.T) {
177 bucket, prefix, region := liveConfig(t)
178 ctx := context.Background()
179
180 dir := t.TempDir()
181 db1 := filepath.Join(dir, "one.db")
182 db2 := filepath.Join(dir, "two.db")
183 seedDB(t, db1)
184 seedDB(t, db2)
185
186 first, err := ReplicateWithLease(ctx, db1, bucket, prefix, region)
187 if err != nil {
188 t.Fatalf("first claimant: %v", err)
189 }
190 defer first.Close(ctx)
191
192 // Second claimant, same bucket+prefix: must fail, and fail typed.
193 second, err := ReplicateWithLease(ctx, db2, bucket, prefix, region)
194 if err == nil {
195 second.Close(ctx)
196 t.Fatal("second claimant acquired the lease while the first held it — mutual exclusion broken")
197 }
198 var lexists *litestream.LeaseExistsError
199 if !errors.As(err, &lexists) {
200 t.Fatalf("second claimant failed, but not with *litestream.LeaseExistsError: %v", err)
201 }
202 t.Logf("second claimant failed loudly (typed): %v", err)
203 t.Logf(" holder=%q expires=%s", lexists.Owner, lexists.ExpiresAt.Format(time.RFC3339))
204
205 // Release, then the next claimant must succeed immediately (no TTL wait).
206 if err := first.Close(ctx); err != nil {
207 t.Fatalf("release: %v", err)
208 }
209 third, err := ReplicateWithLease(ctx, db2, bucket, prefix, region)
210 if err != nil {
211 t.Fatalf("claimant after release should succeed: %v", err)
212 }
213 if err := third.Close(ctx); err != nil {
214 t.Fatalf("third close: %v", err)
215 }
216}
217