Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions free/ci/internal/dispatch/queued_ms_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
77 changes: 77 additions & 0 deletions free/ci/internal/planview/tier_wait_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading