Something went wrong. Try again.
Stitch any CI into Tangled forked from mitchellh.com/tack
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243package main
// Migration tests for the SQLite store. These specifically cover the// upgrade path from a pre-`created_unix_ns` database (the shape every// production tack.db that pre-dates this commit will have on first// boot) to the current schema. The fresh-database path is exercised// implicitly by every other test via newTestStore. These tests are// about the *transition*.
import ( "context" "database/sql" "fmt" "path/filepath" "testing" "time")
// legacyBuildkiteSchema is the buildkite_builds table as it looked// before created_unix_ns was added. We hand-craft a database in this// shape so the migration has something realistic to widen and backfill.//// Note that org is also absent: the migrate() path adds it via ALTER// too, so the test doubles as coverage for two stacked column adds// applying to the same table on the same upgrade.const legacyBuildkiteSchema = `CREATE TABLE buildkite_builds ( build_uuid TEXT PRIMARY KEY, build_number INTEGER NOT NULL, pipeline_slug TEXT NOT NULL, knot TEXT NOT NULL, pipeline_rkey TEXT NOT NULL, workflow TEXT NOT NULL, pipeline_uri TEXT NOT NULL, created_at TEXT NOT NULL);`
// openLegacyStore opens a brand-new sqlite file, hand-installs the// pre-migration schema, and seeds it with the supplied rows. It// returns the file path so the caller can re-open it through the// real openStore (which runs migrate()) and observe the upgrade.//// We deliberately don't go through openStore for the seeding step,// since that would apply the current schema and defeat the point.func openLegacyStore(t *testing.T, rows []legacyRow) string { t.Helper() path := filepath.Join(t.TempDir(), "tack.db") dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_synchronous=NORMAL&_foreign_keys=on", path) db, err := sql.Open("sqlite3", dsn) if err != nil { t.Fatalf("open legacy db: %v", err) } defer db.Close()
if _, err := db.Exec(legacyBuildkiteSchema); err != nil { t.Fatalf("install legacy schema: %v", err) } for _, r := range rows { if _, err := db.Exec( `INSERT INTO buildkite_builds ( build_uuid, build_number, pipeline_slug, knot, pipeline_rkey, workflow, pipeline_uri, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, r.uuid, r.buildNumber, "p", r.knot, r.rkey, r.workflow, "at://x", r.createdAt, ); err != nil { t.Fatalf("seed legacy row %q: %v", r.uuid, err) } } return path}
// legacyRow is the minimal set of fields we need to seed for the// migration tests. Anything unspecified in the legacy schema (org,// created_unix_ns) is filled in by the migration itself.type legacyRow struct { uuid string knot, rkey, workflow string buildNumber int64 createdAt string}
// TestMigrateAddsAndBackfillsCreatedUnixNS exercises the full upgrade:// a database in the old schema gets opened through openStore, which// runs migrate(), which should (a) add the created_unix_ns column and// (b) populate it from each row's existing created_at text.func TestMigrateAddsAndBackfillsCreatedUnixNS(t *testing.T) { older := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) newer := older.Add(time.Nanosecond) // see store_test.go for why this matters
path := openLegacyStore(t, []legacyRow{ { uuid: "older", knot: "k", rkey: "r", workflow: "w", buildNumber: 1, createdAt: older.Format(time.RFC3339Nano), }, { uuid: "newer", knot: "k", rkey: "r", workflow: "w", buildNumber: 2, createdAt: newer.Format(time.RFC3339Nano), }, })
s, err := openStore(path) if err != nil { t.Fatalf("openStore (migrate): %v", err) } defer s.Close()
// Column must now exist and be populated for both seeded rows. got := map[string]int64{} rows, err := s.db.Query(`SELECT build_uuid, created_unix_ns FROM buildkite_builds`) if err != nil { t.Fatalf("select after migrate: %v", err) } for rows.Next() { var uuid string var ns int64 if err := rows.Scan(&uuid, &ns); err != nil { t.Fatalf("scan: %v", err) } got[uuid] = ns } if err := rows.Err(); err != nil { t.Fatalf("iterate: %v", err) }
if got["older"] != older.UnixNano() { t.Errorf("older: created_unix_ns = %d; want %d", got["older"], older.UnixNano()) } if got["newer"] != newer.UnixNano() { t.Errorf("newer: created_unix_ns = %d; want %d", got["newer"], newer.UnixNano()) }
// And the lookup query (the actual reason the column exists) // must surface the newer row, which is the case the original bug // report says was getting it wrong before. ref, err := s.LookupBuildkiteBuildByTuple(context.Background(), "k", "r", "w") if err != nil { t.Fatalf("lookup: %v", err) } if ref == nil || ref.BuildUUID != "newer" { t.Fatalf("lookup picked %+v; want uuid=newer", ref) }}
// TestMigrateIdempotent makes sure running migrate() repeatedly is// safe: re-applying the ALTERs returns "duplicate column name" which// is swallowed, and the backfill sees no rows with created_unix_ns=0// the second time around so it must not clobber the values written// on the first pass.func TestMigrateIdempotent(t *testing.T) { when := time.Date(2026, 5, 1, 12, 34, 56, 789, time.UTC) path := openLegacyStore(t, []legacyRow{{ uuid: "u1", knot: "k", rkey: "r", workflow: "w", buildNumber: 1, createdAt: when.Format(time.RFC3339Nano), }})
// First open: migrate runs, column is added and backfilled. s, err := openStore(path) if err != nil { t.Fatalf("first openStore: %v", err) }
// Run migrate() again on the same handle. Should be a no-op and // must not return an error from a redundant ALTER. if err := s.migrate(context.Background()); err != nil { t.Fatalf("second migrate: %v", err) } s.Close()
// Re-open from scratch (which also re-runs migrate) and confirm // the backfilled value is still intact. s2, err := openStore(path) if err != nil { t.Fatalf("re-open: %v", err) } defer s2.Close()
var ns int64 if err := s2.db.QueryRow( `SELECT created_unix_ns FROM buildkite_builds WHERE build_uuid = ?`, "u1", ).Scan(&ns); err != nil { t.Fatalf("read back: %v", err) } if ns != when.UnixNano() { t.Fatalf("created_unix_ns = %d after repeated migrate; want %d", ns, when.UnixNano()) }}
// TestMigrateBackfillSkipsUnparseableCreatedAt confirms the backfill's// "skip and keep going" behavior for malformed timestamps. A single// corrupt row must not block startup; the row is left at 0 (the// post-ALTER default) and the lookup's tiebreaker columns then take// over for it.func TestMigrateBackfillSkipsUnparseableCreatedAt(t *testing.T) { good := time.Date(2026, 7, 8, 9, 10, 11, 12, time.UTC) path := openLegacyStore(t, []legacyRow{ { uuid: "good", knot: "k", rkey: "r", workflow: "w", buildNumber: 1, createdAt: good.Format(time.RFC3339Nano), }, { uuid: "garbage", knot: "k", rkey: "r", workflow: "w", buildNumber: 2, createdAt: "not a timestamp", }, })
s, err := openStore(path) if err != nil { // The whole point of swallowing parse errors is that // migrate() must succeed regardless. If it returns an // error here, the swallow logic regressed. t.Fatalf("openStore must tolerate unparseable created_at: %v", err) } defer s.Close()
values := map[string]int64{} rows, err := s.db.Query(`SELECT build_uuid, created_unix_ns FROM buildkite_builds`) if err != nil { t.Fatalf("select: %v", err) } for rows.Next() { var uuid string var ns int64 if err := rows.Scan(&uuid, &ns); err != nil { t.Fatalf("scan: %v", err) } values[uuid] = ns } if err := rows.Err(); err != nil { t.Fatalf("iterate: %v", err) }
if values["good"] != good.UnixNano() { t.Errorf("good row: created_unix_ns = %d; want %d", values["good"], good.UnixNano()) } if values["garbage"] != 0 { t.Errorf("garbage row: created_unix_ns = %d; want 0 (left at default)", values["garbage"]) }}