Stitch any CI into Tangled
2

Configure Feed

Select the types of activity you want to include in your feed.

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}