package oltp
import (
"path/filepath"
"testing"
"time"
)
// runAt inserts a run and backdates its start, which AddCIRun always
// stamps with now.
func runAt(t *testing.T, db *DB, id, checkin, pair, status string, started time.Time) {
t.Helper()
if err := db.AddCIRun(CIRun{RunID: id, Repo: "alpha", Checkin: checkin,
Pair: pair, Workflow: "w", Pipeline: "ci/p.json", Status: status}); err != nil {
t.Fatal(err)
}
if _, err := db.db.Exec(`UPDATE ci_run SET started = ? WHERE run_id = ?`,
started.UTC().Format(time.RFC3339), id); err != nil {
t.Fatal(err)
}
}
func runIDs(t *testing.T, db *DB) map[string]bool {
t.Helper()
rows, err := db.CIRuns("alpha", 100)
if err != nil {
t.Fatal(err)
}
out := map[string]bool{}
for _, r := range rows {
out[r.RunID] = true
}
return out
}
func openOLTP(t *testing.T) *DB {
t.Helper()
db, err := Open(filepath.Join(t.TempDir(), "o.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
return db
}
// What the window removes is the RETRIES. A check-in accumulates several
// runs from re-runs and supersessions, and that is where the unbounded
// growth was; the newest survives so the verdict badge can still name
// the run that produced it.
func TestPruningKeepsTheNewestRunPerCheckin(t *testing.T) {
db := openOLTP(t)
old := time.Now().Add(-200 * 24 * time.Hour)
runAt(t, db, "r1", "c1", "", "failed", old)
runAt(t, db, "r2", "c1", "", "failed", old.Add(time.Hour))
runAt(t, db, "r3", "c1", "", "succeeded", old.Add(2*time.Hour))
n, err := db.PruneCIRuns(time.Now().Add(-CIRunKeep))
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("FAIL: retired %d runs, want 2", n)
}
left := runIDs(t, db)
if !left["r3"] {
t.Error("FAIL: the newest run for the check-in was pruned — the badge can no longer name it")
}
if left["r1"] || left["r2"] {
t.Errorf("FAIL: a superseded retry survived: %v", left)
}
}
// An unfinished run is never pruned, whatever its age. The row IS the
// poller's work list, and deleting it strands a run whose verdict
// nobody will ever record.
func TestAnUnfinishedRunIsNeverPruned(t *testing.T) {
db := openOLTP(t)
old := time.Now().Add(-400 * 24 * time.Hour)
runAt(t, db, "done", "c1", "", "succeeded", old)
runAt(t, db, "stuck", "c1", "", "running", old.Add(time.Minute))
if _, err := db.PruneCIRuns(time.Now()); err != nil {
t.Fatal(err)
}
if !runIDs(t, db)["stuck"] {
t.Error("FAIL: an ancient unfinished run was pruned; it is a bug to find, not a row to hide")
}
// And it does not shield the finished ones behind it: "stuck" is not
// a terminal run, so it is not the newest TERMINAL run either.
if !runIDs(t, db)["done"] {
t.Error("FAIL: the only finished run for the check-in went")
}
}
// Nothing inside the window goes, however many retries it holds. The
// window is what makes this retention rather than a cap.
func TestRunsInsideTheWindowStay(t *testing.T) {
db := openOLTP(t)
recent := time.Now().Add(-2 * time.Hour)
runAt(t, db, "a", "c1", "", "failed", recent)
runAt(t, db, "b", "c1", "", "succeeded", recent.Add(time.Minute))
n, err := db.PruneCIRuns(time.Now().Add(-CIRunKeep))
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("FAIL: retired %d runs inside the window", n)
}
}
// Subject is (repo, checkin, pair): a premerge run is about a PAIR of
// tips rather than one check-in (RFC 0009), so a check-in's own run and
// its premerge runs each keep their newest.
func TestAPremergeRunKeepsItsOwnNewest(t *testing.T) {
db := openOLTP(t)
old := time.Now().Add(-200 * 24 * time.Hour)
runAt(t, db, "own1", "c1", "", "failed", old)
runAt(t, db, "own2", "c1", "", "succeeded", old.Add(time.Hour))
runAt(t, db, "pre1", "c1", "c1+t1", "failed", old)
runAt(t, db, "pre2", "c1", "c1+t1", "succeeded", old.Add(time.Hour))
if _, err := db.PruneCIRuns(time.Now().Add(-CIRunKeep)); err != nil {
t.Fatal(err)
}
left := runIDs(t, db)
for _, keep := range []string{"own2", "pre2"} {
if !left[keep] {
t.Errorf("FAIL: %s went; each subject keeps its own newest", keep)
}
}
for _, gone := range []string{"own1", "pre1"} {
if left[gone] {
t.Errorf("FAIL: %s survived", gone)
}
}
}
// A run nobody superseded survives regardless of age: it is the newest
// for its check-in, which is the whole exception.
func TestALoneOldRunSurvives(t *testing.T) {
db := openOLTP(t)
runAt(t, db, "only", "c1", "", "succeeded", time.Now().Add(-3*365*24*time.Hour))
if _, err := db.PruneCIRuns(time.Now()); err != nil {
t.Fatal(err)
}
if !runIDs(t, db)["only"] {
t.Error("FAIL: the only run for a check-in was pruned")
}
}
// The poller's work list and the sweep read ONE status vocabulary. Two
// copies would drift, and the drift would either strand a run nobody
// polls or prune one somebody is waiting on.
func TestTheTerminalVocabularyIsShared(t *testing.T) {
db := openOLTP(t)
old := time.Now().Add(-200 * 24 * time.Hour)
for i, st := range []string{"completed", "succeeded", "success", "failed",
"cancelled", "canceled", "error"} {
runAt(t, db, "t"+string(rune('a'+i)), "c1", "", st, old.Add(time.Duration(i)*time.Hour))
}
unfinished, err := db.UnfinishedCIRuns()
if err != nil {
t.Fatal(err)
}
if len(unfinished) != 0 {
t.Errorf("FAIL: the poller sees %d of these as unfinished: %+v", len(unfinished), unfinished)
}
n, err := db.PruneCIRuns(time.Now())
if err != nil {
t.Fatal(err)
}
if n != 6 {
t.Errorf("FAIL: retired %d of 7 terminal runs (6 plus the newest kept), want 6", n)
}
}