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
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package reconcile

// Purpose: P7-AUTO-11 conditional requests on the head scan: once a full scan has
// stored its ETags, an unchanged repository answers 304 on the repository, the pull
// list and (with extra branches) the branch list, and ScanHeads makes no further
// request; a changed pull list is read in full again.
// Inputs: the HOST-17 fixture forge and engine rig. Outputs: pass or fail.
// Constraints: loopback TLS only; synthetic names and shas.

import (
"context"
"testing"
)

func TestScanHeadsConditional(t *testing.T) {
ctx := context.Background()
r := newRig(t, "o/r")
r.f.do(func() { r.f.repos["o/r"].Pulls = []fPull{{Number: 7, Head: sha('b', 7), Assoc: "OWNER"}} })
opts := []ScanOptions{{}, {Branches: []string{"release/*"}, MaxBranches: 5}}
for _, o := range opts {
first, err := r.e.ScanHeads(ctx, "o/r", o)
if err != nil || first.NotModified || len(first.Heads) == 0 {
t.Fatalf("the first scan must list the heads: %+v %v", first, err)
}
r.f.reset()
again, err := r.e.ScanHeads(ctx, "o/r", o)
if err != nil || !again.NotModified || len(again.Heads) != 0 {
t.Fatalf("an unchanged repository must answer NotModified: %+v %v", again, err)
}
want := 2 // the repository and the pull list
if len(o.Branches) > 0 {
want = 3 // and the branch list
}
if n := r.f.total(); n != want || r.f.nonCond304() != 0 || again.Calls != want {
t.Fatalf("a 304 pass must make exactly %d conditional requests: total=%d non-304=%d calls=%d", want, n, r.f.nonCond304(), again.Calls)
}
if r.f.count(func(q reqRec) bool { return !q.Cond }) != 0 {
t.Fatal("every repeated read must be conditional")
}
}

// A new pull request changes the list: the scan reads it and reports the new head.
r.f.do(func() {
r.f.repos["o/r"].Pulls = append(r.f.repos["o/r"].Pulls, fPull{Number: 8, Head: sha('c', 8), Assoc: "OWNER"})
})
r.f.reset()
changed, err := r.e.ScanHeads(ctx, "o/r", ScanOptions{})
if err != nil || changed.NotModified {
t.Fatalf("a changed pull list must not be NotModified: %+v %v", changed, err)
}
found := false
for _, h := range changed.Heads {
found = found || h.PR == 8
}
if !found {
t.Fatalf("the new pull request head is missing: %+v", changed.Heads)
}
}
4 changes: 2 additions & 2 deletions free/ci/internal/providers/github/reconcile/scan_export.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ type ghCommit struct {
}

// ScanHeads lists the candidate heads of repo for the watcher.
func (e *Engine) ScanHeads(ctx context.Context, repo string, o ScanOptions) (HeadScan, error) {
var res HeadScan
// The result is named so the deferred call count reaches the caller on every return.
func (e *Engine) ScanHeads(ctx context.Context, repo string, o ScanOptions) (res HeadScan, err error) {
s, err := e.session(ctx, kindScan, e.passLimit())
if err != nil {
return res, err
Expand Down
80 changes: 80 additions & 0 deletions free/ci/internal/watch/conditional_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package watch

// Purpose: P7-AUTO-11 conditional requests on the watcher side: a scan whose
// conditional reads all answered 304 keeps the previous heads (no fresh listing
// is needed to act on them), a 304 before any listing acts on nothing, and the
// passes of two hosts are offset and jittered apart.
// Inputs: the shared rig (fake ledger, scripted runner) and a scripted scanner.
// Outputs: pass or fail. Constraints: no network, no sleeps.

import (
"context"
"testing"
"time"
)

// notModScan answers the first scan with heads and every later one with 304.
type notModScan struct {
heads []Head
calls int
}

func (s *notModScan) Scan(context.Context, string) (Scan, error) {
s.calls++
if s.calls == 1 {
return Scan{Heads: append([]Head(nil), s.heads...)}, nil
}
return Scan{NotModified: true}, nil
}

func TestConditionalRequests(t *testing.T) {
ctx := context.Background()

// A 304 keeps the heads of the last full scan: the pass still sees them (here: already final).
r := newRig(t, ownerHead("o/r", 1))
scan := &notModScan{heads: r.scan.heads}
r.w.P.Scanner = scan
if rep := r.w.Pass(ctx); rep.Ran != 1 {
t.Fatalf("the first pass must run the head: %+v", rep)
}
rep := r.w.Pass(ctx)
if scan.calls != 2 || len(rep.Decisions) != 1 || rep.Decisions[0].SHA != sha(1) || rep.Ran != 0 {
t.Fatalf("a 304 scan must keep the previous heads and run nothing new: calls=%d %+v", scan.calls, rep)
}
if r.runner.count() != 1 {
t.Fatalf("a 304 scan must not rerun a head: %d runs", r.runner.count())
}

// A 304 before any full scan has nothing to act on.
fresh := newRig(t)
fresh.w.P.Scanner = &notModScan{calls: 1}
if rep := fresh.w.Pass(ctx); len(rep.Decisions) != 0 || rep.Ran != 0 {
t.Fatalf("a 304 without a previous scan must act on nothing: %+v", rep)
}

// Two hosts poll out of step: distinct phase offsets inside one interval, and a
// jittered wait within [0.9, 1.1) of the interval.
c := Defaults()
c.Interval = time.Minute
mk := func(host string, jitter float64) *Watcher {
c.Host = host
w, err := New(c, Ports{Scanner: scan, Ledger: r.gh.on(host), Reporter: r.gh.on(host), Runner: r.runner, Jitter: func() float64 { return jitter }})
if err != nil {
t.Fatal(err)
}
return w
}
a, b := mk("mini1", 0), mk("mini2", 0.999)
if a.Offset() == b.Offset() {
t.Fatalf("two hosts must not share a phase offset: %v", a.Offset())
}
for _, w := range []*Watcher{a, b} {
if o := w.Offset(); o < 0 || o >= c.Interval {
t.Fatalf("offset %v is outside one interval", o)
}
}
now := time.Date(2026, 10, 28, 12, 0, 0, 0, time.UTC)
if lo, hi := a.Next(now), b.Next(now); lo != 54*time.Second || hi < lo || hi >= 66*time.Second {
t.Fatalf("jittered waits %v and %v must lie in [0.9, 1.1) of the interval", lo, hi)
}
}
Loading