Skip to content
quarry Artifact [a27a5b209d]
Login

Artifact [a27a5b209d]

Artifact a27a5b209d984245a8b64c1e338b90133228dae35fc7126af0c99b38df3e4aeb:


package ci

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"
)

func schedSpec(lines ...string) []byte {
	var b strings.Builder
	b.WriteString("on:\n  schedule:\n")
	for _, l := range lines {
		b.WriteString("    - cron: '" + l + "'\n")
	}
	b.WriteString("jobs:\n  t:\n    steps:\n      - run: x\n")
	return []byte(b.String())
}

// THE 5-MINUTE FLOOR HOLDS FOR THE WORKFLOW, NOT LINE BY LINE, AND FOR
// EVERY DAY, NOT A SAMPLE: the minute sets of all lines merge, and the
// cyclic gap between consecutive minutes is at least five.
func TestTheFloorHoldsAcrossLinesAndDays(t *testing.T) {
	var offsets []string
	for i := 0; i < 5; i++ {
		offsets = append(offsets, fmt.Sprintf("%d-59/5 * * * *", i))
	}
	for name, lines := range map[string][]string{
		"two lines two minutes apart":    {"0 * * * *", "2 * * * *"},
		"offset */5 lines every minute":  offsets,
		"every minute on a leap day":     {"* * 29 2 *"},
		"step under five":                {"*/3 4 * * *"},
		"wrapping across the hour":       {"58 * * * *", "1 * * * *"},
		"03:17 beside 03:15 on weekdays": {"17 3 * * *", "*/15 * * * 1-5"},
		"wrapping across midnight":       {"58 23 * * *", "1 0 * * *"},
		"eleven lines":                   {"0 0 * * *", "5 0 * * *", "10 0 * * *", "15 0 * * *", "20 0 * * *", "25 0 * * *", "30 0 * * *", "35 0 * * *", "40 0 * * *", "45 0 * * *", "50 0 * * *"},
	} {
		if _, err := schedulesOf(schedSpec(lines...)); err == nil || !strings.Contains(err.Error(), "line ") {
			t.Errorf("FAIL: %s %v was accepted (%v); want a refusal with a position", name, lines, err)
		}
	}
	for name, lines := range map[string][]string{
		"hourly and half past": {"0 * * * *", "30 * * * *"},
		"every five":           {"*/5 * * * *"},
		"leap day once":        {"0 12 29 2 *"},
		"wrap of exactly five": {"57 * * * *", "2 * * * *"},
		"an hour apart":        {"0 3 * * *", "2 4 * * *"},
	} {
		if _, err := schedulesOf(schedSpec(lines...)); err != nil {
			t.Errorf("FAIL: %s %v was refused: %v", name, lines, err)
		}
	}
	if _, err := schedulesOf(schedSpec(strings.Repeat("1", 300) + " * * * *")); err == nil {
		t.Errorf("FAIL: an over-long cron line was accepted")
	}
	t.Log("ok: the floor is the workflow's minute set, cyclic, every day; lines capped; an over-long line refused, never a panic")
}

type countingRepo struct {
	*fakeRepo
	mu    sync.Mutex
	reads int
}

func (c *countingRepo) Read(p, v string) ([]byte, error) {
	c.mu.Lock()
	c.reads++
	c.mu.Unlock()
	return c.fakeRepo.Read(p, v)
}

type failStore struct {
	*fakeStore
	scheduleRig
	failClaim string
	failed    []string
}

func (f *failStore) ClaimSchedule(repo, p string, m time.Time) (bool, error) {
	if p == f.failClaim {
		return false, errors.New("database is locked")
	}
	return f.scheduleRig.ClaimSchedule(repo, p, m)
}
func (f *failStore) ScheduleFailed(repo, p string, m time.Time, why string) {
	f.failed = append(f.failed, p+": "+why)
}

// ONE BAD WORKFLOW STOPS ONLY ITSELF; THE SCHEDULES ARE READ ONCE PER
// DEFAULT TIP; A LOST LEASE STOPS CLAIMS; A REFUSED SCHEDULE IS LISTED
// AS NOT FIRING, WITH ITS ERROR.
func TestSchedulesAreReadOncePerTipAndFailAlone(t *testing.T) {
	var mu sync.Mutex
	started := map[string]int{}
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		mu.Lock()
		defer mu.Unlock()
		switch {
		case r.URL.Path == "/v1/ci/compile":
			var body struct{ Name string }
			json.NewDecoder(r.Body).Decode(&body)
			fmt.Fprintf(w, `{"workflow":{"name":%q},"registered":true,"on":{"schedule":[]}}`, body.Name)
		case r.URL.Path == "/runs" && r.Method == http.MethodPost:
			var body struct{ Input map[string]any }
			json.NewDecoder(r.Body).Decode(&body)
			started[fmt.Sprint(body.Input["pipeline"])]++
			w.WriteHeader(http.StatusCreated)
			fmt.Fprintf(w, `{"run_id":"r%d"}`, len(started))
		case r.URL.Path == "/runs":
			fmt.Fprint(w, `[]`)
		}
	}))
	defer srv.Close()
	e := New(srv.URL)
	repo := &countingRepo{fakeRepo: &fakeRepo{
		files: []string{"ci/a.yml", "ci/bad.yml", "ci/b.yml", "ci/c.yml"},
		blobs: map[string][]byte{
			"ci/a.yml":   schedSpec("0 * * * *"),
			"ci/bad.yml": schedSpec("* * * * *"),
			"ci/b.yml":   schedSpec("0 * * * *"),
			"ci/c.yml":   schedSpec("0 * * * *"),
		},
		tips: map[string]string{"trunk": "t1"},
	}}
	st := &failStore{fakeStore: &fakeStore{mark: map[string]string{}},
		scheduleRig: scheduleRig{claims: map[string]bool{}, running: map[string]bool{}, mu: &sync.Mutex{}}, failClaim: "ci/b.yml"}
	cache := NewScheduleCache()
	base := time.Date(2026, 10, 3, 3, 0, 0, 0, time.UTC)
	minutes := []time.Time{base, base.Add(time.Minute), base.Add(2 * time.Minute)}
	if err := FireSchedules(e, st, cache, "alpha", repo, "u", minutes, always); err != nil {
		t.Errorf("FAIL: one bad workflow failed the repository: %v", err)
	}
	if started["ci/a.yml"] != 1 || started["ci/c.yml"] != 1 || started["ci/bad.yml"] != 0 || started["ci/b.yml"] != 0 {
		t.Errorf("FAIL: started %v; want a and c once, the refused and the unclaimable none", started)
	}
	readsAfterFirst := repo.reads
	if err := FireSchedules(e, st, cache, "alpha", repo, "u", []time.Time{base.Add(time.Hour)}, always); err != nil {
		t.Fatal(err)
	}
	// launch reads the two it starts; the schedules themselves are not read again
	if repo.reads-readsAfterFirst > 2 {
		t.Errorf("FAIL: an unchanged tip was read %d more times; want only the launches' own reads", repo.reads-readsAfterFirst)
	}
	listed, err := cache.Schedules("alpha", repo, base)
	if err != nil {
		t.Fatal(err)
	}
	var bad *Scheduled
	for i := range listed {
		if listed[i].Pipeline == "ci/bad.yml" {
			bad = &listed[i]
		}
	}
	if bad == nil || bad.Error == "" || !bad.Next.IsZero() {
		t.Errorf("FAIL: the refused schedule is listed as %+v; want not firing, with its error", bad)
	}
	// The lease lost between claims: nothing more is claimed.
	n := len(st.claims)
	if err := FireSchedules(e, st, cache, "alpha", repo, "u", []time.Time{base.Add(2 * time.Hour)}, func() bool { return false }); err != nil {
		t.Fatal(err)
	}
	if len(st.claims) != n {
		t.Errorf("FAIL: claims were taken without the lease")
	}
	t.Log("ok: a bad or unclaimable workflow skips alone; one read per tip; no lease, no claim; a refused schedule is listed with its error")
}

// THE TICK LOOP: NO BACKFILL AT START, EVERY MINUTE SINCE THE LAST ONE
// WHILE LEADING, NOTHING WHILE NOT, CATCH-UP BOUNDED.
func TestTheScheduleTickWalksMinutes(t *testing.T) {
	at := func(h, m, s int) time.Time { return time.Date(2026, 10, 3, h, m, s, 0, time.UTC) }
	tk := NewScheduleTick(at(3, 0, 20))
	if got := tk.Minutes(at(3, 0, 35), true); len(got) != 1 || !got[0].Equal(at(3, 0, 0)) {
		t.Errorf("FAIL: the first tick walked %v; want the current minute only", got)
	}
	if got := tk.Minutes(at(3, 0, 50), true); len(got) != 0 {
		t.Errorf("FAIL: a second tick in the same minute walked %v", got)
	}
	if got := tk.Minutes(at(3, 3, 5), true); len(got) != 3 {
		t.Errorf("FAIL: a slow tick walked %v; want the three minutes since", got)
	}
	if got := tk.Minutes(at(3, 5, 0), false); len(got) != 0 {
		t.Errorf("FAIL: a node without the lease walked %v", got)
	}
	if got := tk.Minutes(at(3, 6, 0), true); len(got) != 1 {
		t.Errorf("FAIL: regaining the lease walked %v; want only the current minute", got)
	}
	if got := tk.Minutes(at(4, 0, 0), true); len(got) != ScheduleCatchUp {
		t.Errorf("FAIL: an hour's stall walked %d minutes; want the bound %d", len(got), ScheduleCatchUp)
	}
	t.Log("ok: no backfill, minute by minute while leading, nothing while not, bounded catch-up")
}