From 52f1e08d9b1d184e1be03f09a3edfbbbfce803ca Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 13:42:06 -0400 Subject: [PATCH] test(ci): queue age feeds the auto tier wait, plan shows tier and wait (P7-AUTO-05) TestDispatchFillsQueuedMs: place derives QueuedMs from EnqueuedAt at the tick clock; a job never enqueued or a clock that went back reads as no wait. TestPlanShowsTierAndWait: under auto the held job's cell carries tier.waiting with the wait left, the chosen line shows tier and bias, and other presets print no tier text. --- free/ci/internal/dispatch/queued_ms_test.go | 85 +++++++++++++++++++++ free/ci/internal/planview/tier_wait_test.go | 77 +++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 free/ci/internal/dispatch/queued_ms_test.go create mode 100644 free/ci/internal/planview/tier_wait_test.go diff --git a/free/ci/internal/dispatch/queued_ms_test.go b/free/ci/internal/dispatch/queued_ms_test.go new file mode 100644 index 000000000..d3546a7cf --- /dev/null +++ b/free/ci/internal/dispatch/queued_ms_test.go @@ -0,0 +1,85 @@ +package dispatch + +// Purpose: place fills each job's QueuedMs from its EnqueuedAt at the tick's +// clock (P7-AUTO-05), so the `auto` tier wait is bounded by real queue age; a +// job never enqueued, or a clock that went back, reads as no wait at all. +// Inputs: a two-runner `auto` world whose better tier is busy; a fixed now. +// Outputs: pass or fail. Constraints: no store, no clock reads. + +import ( + "regexp" + "strconv" + "testing" + "time" + + "github.com/nself-org/plugins/free/ci/internal/model" + "github.com/nself-org/plugins/free/ci/internal/sched" +) + +var msLeftRe = regexp.MustCompile(`: (\d+) ms left of step_sec`) + +// autoBusyWorld has a busy runner in the better tier and a free one a tier lower. +func autoBusyWorld() sched.World { + better, worse := testRunner("better", "lan", 0), testRunner("worse", "lan", 1) + net := map[string]sched.NetworkFact{} + for _, id := range []string{"better", "worse"} { + net[id] = sched.NetworkFact{Enforced: []model.NetworkScope{"none", "restricted", "internet"}} + } + return sched.World{Network: net, Runners: []sched.Runner{better, worse}, Policy: sched.Policy{Preset: sched.AutoPreset}, + Costs: map[string]sched.Cost{}, Previous: map[string]string{}, Counts: sched.Counts{Runners: map[string]int{}, Providers: map[string]int{}}, + Bias: map[string]int64{"better": 0, "worse": sched.TierStepMs}} +} + +func TestDispatchFillsQueuedMs(t *testing.T) { + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + step := sched.DefaultAutoStepSec * 1000 + cases := []struct { + name string + enqueued time.Time + placed bool // past step_sec: runs on the lower tier + left int64 // otherwise: the wait left that the reason reports + }{ + {"never enqueued reads as no wait", time.Time{}, false, step}, + {"waited five seconds", now.Add(-5 * time.Second), false, step - 5000}, + {"a clock that went back never adds wait", now.Add(10 * time.Second), false, step}, + {"waited past step_sec", now.Add(-time.Duration(step+1000) * time.Millisecond), true, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + q := Queued{Job: testQueued("a1"), EnqueuedAt: c.enqueued} + res := place(autoBusyWorld(), []Queued{q}, now) + if c.placed { + if len(res.Placements) != 1 || res.Placements[0].RunnerID != "worse" { + t.Fatalf("a job past step_sec must run on the lower tier: %+v", res) + } + return + } + if len(res.Placements) != 0 || len(res.Explanations) != 1 || res.Explanations[0].Status != "waiting" { + t.Fatalf("the job must wait for the better tier: %+v", res) + } + var detail string + for _, r := range res.Explanations[0].Reasons { + if r.Code == string(model.TierWaiting) { + detail = r.Detail + } + } + m := msLeftRe.FindStringSubmatch(detail) + if m == nil { + t.Fatalf("no tier.waiting detail with the wait left: %+v", res.Explanations[0].Reasons) + } + if got, _ := strconv.ParseInt(m[1], 10, 64); got != c.left { + t.Fatalf("wait left = %d ms, want %d (QueuedMs from EnqueuedAt at now): %s", got, c.left, detail) + } + }) + } +} + +// Plan uses the dispatcher's clock, the same path as a tick. +func TestDispatchPlanFillsQueuedMsFromItsClock(t *testing.T) { + now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC) + d := &Dispatcher{cfg: Config{Orderer: FIFO{}, Clock: func() time.Time { return now }}} + stale := Queued{Job: testQueued("a1"), EnqueuedAt: now.Add(-time.Hour)} + if _, res := d.Plan(autoBusyWorld(), []Queued{stale}); len(res.Placements) != 1 || res.Placements[0].RunnerID != "worse" { + t.Fatalf("an hour-old job must not wait for the busy tier: %+v", res) + } +} diff --git a/free/ci/internal/planview/tier_wait_test.go b/free/ci/internal/planview/tier_wait_test.go new file mode 100644 index 000000000..fcf93698f --- /dev/null +++ b/free/ci/internal/planview/tier_wait_test.go @@ -0,0 +1,77 @@ +package planview + +// Purpose: under the `auto` preset `ci plan` and `ci explain` show the chosen +// runner's tier and bias and, for a held job, the tier.waiting detail with the +// wait left (P7-AUTO-05); every other preset's lines stay as they were. +// Inputs: a two-runner world (busy better tier, free lower tier) and the +// fixture's linux jobs. Outputs: pass or fail. + +import ( + "strings" + "testing" + + "github.com/nself-org/plugins/free/ci/internal/model" + "github.com/nself-org/plugins/free/ci/internal/sched" +) + +// tierWorld puts a busy runner in the better tier and a free one a tier lower; +// the tier index comes from Bias, the way internal/tiers writes it. +func tierWorld(t *testing.T, preset string) sched.World { + t.Helper() + w, _, _ := fixture(t) + busy, free := w.Runners[0], w.Runners[0] + busy.ID, busy.Avail.Slots = "busy-local", 0 + busy.Capability.Identity.ID = busy.ID + free.ID = "free-local" + free.Capability.Identity.ID = free.ID + w.Runners = []sched.Runner{busy, free} + w.Policy.Preset = preset + w.Bias = map[string]int64{"busy-local": 0, "free-local": sched.TierStepMs} + return w +} + +func tierDoc(w sched.World, jobs []sched.Job) model.PlacementPlan { + return Build(Input{Name: "default", Revision: strings.Repeat("a", 40), Trigger: "local", PolicyDigest: "p" + strings.Repeat("0", 63), + WorldDigest: "w" + strings.Repeat("0", 63), World: w, Jobs: jobs, Result: sched.Place(w, jobs)}) +} + +func linuxBuild(t *testing.T, queuedMs int64) []sched.Job { + t.Helper() + _, jobs, _ := fixture(t) + for _, j := range jobs { + if j.Name == "build" { + j.QueuedMs = queuedMs + return []sched.Job{j} + } + } + t.Fatal("fixture has no build job") + return nil +} + +func TestPlanShowsTierAndWait(t *testing.T) { + // A fresh job waits for the busy better tier: the cell names tier.waiting and the wait left. + held := tierDoc(tierWorld(t, sched.AutoPreset), linuxBuild(t, 0)) + cells := Cells(held) + if len(cells) != 1 || !strings.Contains(cells[0], string(model.TierWaiting)) || !strings.Contains(cells[0], "ms left of step_sec") { + t.Fatalf("a held job's cell must show tier.waiting with the wait left: %q", cells) + } + if !strings.Contains(cells[0], "busy-local") { + t.Fatalf("the cell must name the held better runner: %q", cells[0]) + } + + // Past step_sec it runs on the lower tier, and the chosen line shows its tier and bias. + ran := tierDoc(tierWorld(t, sched.AutoPreset), linuxBuild(t, sched.DefaultAutoStepSec*1000+1)) + lines := strings.Join(ExplainLines(ran, SourcePlan), "\n") + if !strings.Contains(lines, "chosen free-local") { + t.Fatalf("the job must run on the lower tier after the wait:\n%s", lines) + } + if !strings.Contains(lines, " tier local bias ") { + t.Fatalf("the auto chosen line must show tier and bias:\n%s", lines) + } + + // Another preset with the same world prints no tier text. + plain := strings.Join(ExplainLines(tierDoc(tierWorld(t, "balanced"), linuxBuild(t, 0)), SourcePlan), "\n") + if strings.Contains(plain, " tier ") || strings.Contains(plain, string(model.TierWaiting)) { + t.Fatalf("a non-auto preset must not print tier text:\n%s", plain) + } +}