Stitch any CI into Tangled
1package main
2
3// Migration tests for the SQLite store. These specifically cover the
4// upgrade path from a pre-`created_unix_ns` database (the shape every
5// production tack.db that pre-dates this commit will have on first
6// boot) to the current schema. The fresh-database path is exercised
7// implicitly by every other test via newTestStore. These tests are
8// about the *transition*.
9
10import (
11 "context"
12 "database/sql"
13 "fmt"
14 "path/filepath"
15 "testing"
16 "time"
17)
18
19// legacyBuildkiteSchema is the buildkite_builds table as it looked
20// before created_unix_ns was added. We hand-craft a database in this
21// shape so the migration has something realistic to widen and backfill.
22//
23// Note that org is also absent: the migrate() path adds it via ALTER
24// too, so the test doubles as coverage for two stacked column adds
25// applying to the same table on the same upgrade.
26const legacyBuildkiteSchema = `
27CREATE TABLE buildkite_builds (
28 build_uuid TEXT PRIMARY KEY,
29 build_number INTEGER NOT NULL,
30 pipeline_slug TEXT NOT NULL,
31 knot TEXT NOT NULL,
32 pipeline_rkey TEXT NOT NULL,
33 workflow TEXT NOT NULL,
34 pipeline_uri TEXT NOT NULL,
35 created_at TEXT NOT NULL
36);
37`
38
39// openLegacyStore opens a brand-new sqlite file, hand-installs the
40// pre-migration schema, and seeds it with the supplied rows. It
41// returns the file path so the caller can re-open it through the
42// real openStore (which runs migrate()) and observe the upgrade.
43//
44// We deliberately don't go through openStore for the seeding step,
45// since that would apply the current schema and defeat the point.
46func openLegacyStore(t *testing.T, rows []legacyRow) string {
47 t.Helper()
48 path := filepath.Join(t.TempDir(), "tack.db")
49 dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_synchronous=NORMAL&_foreign_keys=on", path)
50 db, err := sql.Open("sqlite3", dsn)
51 if err != nil {
52 t.Fatalf("open legacy db: %v", err)
53 }
54 defer db.Close()
55
56 if _, err := db.Exec(legacyBuildkiteSchema); err != nil {
57 t.Fatalf("install legacy schema: %v", err)
58 }
59 for _, r := range rows {
60 if _, err := db.Exec(
61 `INSERT INTO buildkite_builds (
62 build_uuid, build_number, pipeline_slug,
63 knot, pipeline_rkey, workflow,
64 pipeline_uri, created_at
65 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
66 r.uuid, r.buildNumber, "p",
67 r.knot, r.rkey, r.workflow,
68 "at://x", r.createdAt,
69 ); err != nil {
70 t.Fatalf("seed legacy row %q: %v", r.uuid, err)
71 }
72 }
73 return path
74}
75
76// legacyRow is the minimal set of fields we need to seed for the
77// migration tests. Anything unspecified in the legacy schema (org,
78// created_unix_ns) is filled in by the migration itself.
79type legacyRow struct {
80 uuid string
81 knot, rkey, workflow string
82 buildNumber int64
83 createdAt string
84}
85
86// TestMigrateAddsAndBackfillsCreatedUnixNS exercises the full upgrade:
87// a database in the old schema gets opened through openStore, which
88// runs migrate(), which should (a) add the created_unix_ns column and
89// (b) populate it from each row's existing created_at text.
90func TestMigrateAddsAndBackfillsCreatedUnixNS(t *testing.T) {
91 older := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
92 newer := older.Add(time.Nanosecond) // see store_test.go for why this matters
93
94 path := openLegacyStore(t, []legacyRow{
95 {
96 uuid: "older", knot: "k", rkey: "r", workflow: "w",
97 buildNumber: 1, createdAt: older.Format(time.RFC3339Nano),
98 },
99 {
100 uuid: "newer", knot: "k", rkey: "r", workflow: "w",
101 buildNumber: 2, createdAt: newer.Format(time.RFC3339Nano),
102 },
103 })
104
105 s, err := openStore(path)
106 if err != nil {
107 t.Fatalf("openStore (migrate): %v", err)
108 }
109 defer s.Close()
110
111 // Column must now exist and be populated for both seeded rows.
112 got := map[string]int64{}
113 rows, err := s.db.Query(`SELECT build_uuid, created_unix_ns FROM buildkite_builds`)
114 if err != nil {
115 t.Fatalf("select after migrate: %v", err)
116 }
117 for rows.Next() {
118 var uuid string
119 var ns int64
120 if err := rows.Scan(&uuid, &ns); err != nil {
121 t.Fatalf("scan: %v", err)
122 }
123 got[uuid] = ns
124 }
125 if err := rows.Err(); err != nil {
126 t.Fatalf("iterate: %v", err)
127 }
128
129 if got["older"] != older.UnixNano() {
130 t.Errorf("older: created_unix_ns = %d; want %d", got["older"], older.UnixNano())
131 }
132 if got["newer"] != newer.UnixNano() {
133 t.Errorf("newer: created_unix_ns = %d; want %d", got["newer"], newer.UnixNano())
134 }
135
136 // And the lookup query (the actual reason the column exists)
137 // must surface the newer row, which is the case the original bug
138 // report says was getting it wrong before.
139 ref, err := s.LookupBuildkiteBuildByTuple(context.Background(), "k", "r", "w")
140 if err != nil {
141 t.Fatalf("lookup: %v", err)
142 }
143 if ref == nil || ref.BuildUUID != "newer" {
144 t.Fatalf("lookup picked %+v; want uuid=newer", ref)
145 }
146}
147
148// TestMigrateIdempotent makes sure running migrate() repeatedly is
149// safe: re-applying the ALTERs returns "duplicate column name" which
150// is swallowed, and the backfill sees no rows with created_unix_ns=0
151// the second time around so it must not clobber the values written
152// on the first pass.
153func TestMigrateIdempotent(t *testing.T) {
154 when := time.Date(2026, 5, 1, 12, 34, 56, 789, time.UTC)
155 path := openLegacyStore(t, []legacyRow{{
156 uuid: "u1", knot: "k", rkey: "r", workflow: "w",
157 buildNumber: 1, createdAt: when.Format(time.RFC3339Nano),
158 }})
159
160 // First open: migrate runs, column is added and backfilled.
161 s, err := openStore(path)
162 if err != nil {
163 t.Fatalf("first openStore: %v", err)
164 }
165
166 // Run migrate() again on the same handle. Should be a no-op and
167 // must not return an error from a redundant ALTER.
168 if err := s.migrate(context.Background()); err != nil {
169 t.Fatalf("second migrate: %v", err)
170 }
171 s.Close()
172
173 // Re-open from scratch (which also re-runs migrate) and confirm
174 // the backfilled value is still intact.
175 s2, err := openStore(path)
176 if err != nil {
177 t.Fatalf("re-open: %v", err)
178 }
179 defer s2.Close()
180
181 var ns int64
182 if err := s2.db.QueryRow(
183 `SELECT created_unix_ns FROM buildkite_builds WHERE build_uuid = ?`, "u1",
184 ).Scan(&ns); err != nil {
185 t.Fatalf("read back: %v", err)
186 }
187 if ns != when.UnixNano() {
188 t.Fatalf("created_unix_ns = %d after repeated migrate; want %d", ns, when.UnixNano())
189 }
190}
191
192// TestMigrateBackfillSkipsUnparseableCreatedAt confirms the backfill's
193// "skip and keep going" behavior for malformed timestamps. A single
194// corrupt row must not block startup; the row is left at 0 (the
195// post-ALTER default) and the lookup's tiebreaker columns then take
196// over for it.
197func TestMigrateBackfillSkipsUnparseableCreatedAt(t *testing.T) {
198 good := time.Date(2026, 7, 8, 9, 10, 11, 12, time.UTC)
199 path := openLegacyStore(t, []legacyRow{
200 {
201 uuid: "good", knot: "k", rkey: "r", workflow: "w",
202 buildNumber: 1, createdAt: good.Format(time.RFC3339Nano),
203 },
204 {
205 uuid: "garbage", knot: "k", rkey: "r", workflow: "w",
206 buildNumber: 2, createdAt: "not a timestamp",
207 },
208 })
209
210 s, err := openStore(path)
211 if err != nil {
212 // The whole point of swallowing parse errors is that
213 // migrate() must succeed regardless. If it returns an
214 // error here, the swallow logic regressed.
215 t.Fatalf("openStore must tolerate unparseable created_at: %v", err)
216 }
217 defer s.Close()
218
219 values := map[string]int64{}
220 rows, err := s.db.Query(`SELECT build_uuid, created_unix_ns FROM buildkite_builds`)
221 if err != nil {
222 t.Fatalf("select: %v", err)
223 }
224 for rows.Next() {
225 var uuid string
226 var ns int64
227 if err := rows.Scan(&uuid, &ns); err != nil {
228 t.Fatalf("scan: %v", err)
229 }
230 values[uuid] = ns
231 }
232 if err := rows.Err(); err != nil {
233 t.Fatalf("iterate: %v", err)
234 }
235
236 if values["good"] != good.UnixNano() {
237 t.Errorf("good row: created_unix_ns = %d; want %d", values["good"], good.UnixNano())
238 }
239 if values["garbage"] != 0 {
240 t.Errorf("garbage row: created_unix_ns = %d; want 0 (left at default)", values["garbage"])
241 }
242}