| 1 | package main
|
| 2 |
|
| 3 | import (
|
| 4 | "errors"
|
| 5 | "fmt"
|
| 6 | "strings"
|
| 7 | "testing"
|
| 8 | "time"
|
| 9 | )
|
| 10 |
|
| 11 | func TestPercentileNearestRank(t *testing.T) {
|
| 12 | xs := []float64{10, 20, 30, 40, 50, 60, 70, 80, 90, 100}
|
| 13 | cases := []struct {
|
| 14 | p float64
|
| 15 | want float64
|
| 16 | }{
|
| 17 | {50, 50}, // ceil(0.5*10)=5 -> idx 4 -> 50
|
| 18 | {95, 100},
|
| 19 | {99, 100},
|
| 20 | {100, 100},
|
| 21 | {10, 10},
|
| 22 | }
|
| 23 | for _, c := range cases {
|
| 24 | if got := percentile(xs, c.p); got != c.want {
|
| 25 | t.Errorf("percentile(%v) = %v, want %v", c.p, got, c.want)
|
| 26 | }
|
| 27 | }
|
| 28 | if got := percentile(nil, 50); got != 0 {
|
| 29 | t.Errorf("percentile(nil) = %v, want 0", got)
|
| 30 | }
|
| 31 | }
|
| 32 |
|
| 33 | func TestSummarizeDropsWarmupAndCountsOutcomes(t *testing.T) {
|
| 34 | warmup := 10 * time.Second
|
| 35 | window := 10 * time.Second
|
| 36 | samples := []sample{
|
| 37 | {offset: 5 * time.Second, dur: 1 * time.Second, res: outcomeOK}, // warm-up: dropped
|
| 38 | {offset: 11 * time.Second, dur: 100 * time.Millisecond, res: outcomeOK},
|
| 39 | {offset: 12 * time.Second, dur: 300 * time.Millisecond, res: outcomeOK},
|
| 40 | {offset: 13 * time.Second, res: outcomeCAS},
|
| 41 | {offset: 14 * time.Second, res: outcomeErr},
|
| 42 | }
|
| 43 | r := summarize(samples, 8, "branch", 1, 3, warmup, window, "1-10 x 2048B")
|
| 44 |
|
| 45 | if r.Pushes != 4 {
|
| 46 | t.Errorf("Pushes = %d, want 4 (warm-up sample excluded)", r.Pushes)
|
| 47 | }
|
| 48 | if r.OK != 2 || r.CASFailures != 1 || r.OtherErrors != 1 {
|
| 49 | t.Errorf("outcomes ok=%d cas=%d err=%d, want 2/1/1", r.OK, r.CASFailures, r.OtherErrors)
|
| 50 | }
|
| 51 | if want := float64(2) / window.Seconds(); r.OpsPerSec != want {
|
| 52 | t.Errorf("OpsPerSec = %v, want %v", r.OpsPerSec, want)
|
| 53 | }
|
| 54 | // Percentiles computed over OK durations only (100ms, 300ms).
|
| 55 | if r.P50ms != 100 || r.Maxms != 300 {
|
| 56 | t.Errorf("p50=%v max=%v, want 100/300", r.P50ms, r.Maxms)
|
| 57 | }
|
| 58 | if r.Concurrency != 8 || r.Nodes != 3 {
|
| 59 | t.Errorf("metadata not carried through: %+v", r)
|
| 60 | }
|
| 61 | }
|
| 62 |
|
| 63 | func TestClassifyOak(t *testing.T) {
|
| 64 | cases := []struct {
|
| 65 | name string
|
| 66 | err error
|
| 67 | want outcome
|
| 68 | }{
|
| 69 | {"nil", nil, outcomeOK},
|
| 70 | {"success false", errors.New("push rejected: success:false (cas contention)"), outcomeCAS},
|
| 71 | {"conflict text", errors.New("server: ConflictDetected on branch head"), outcomeCAS},
|
| 72 | {"expected head", errors.New("push failed: expected head mismatch"), outcomeCAS},
|
| 73 | {"diverged", errors.New("branch diverged from remote"), outcomeCAS},
|
| 74 | {"transport", errors.New("dial tcp: connection refused"), outcomeErr},
|
| 75 | {"http 500", errors.New("oak [push]: HTTP 500 Internal Server Error"), outcomeErr},
|
| 76 | }
|
| 77 | for _, c := range cases {
|
| 78 | if got := classifyOak(c.err); got != c.want {
|
| 79 | t.Errorf("%s: classifyOak = %v, want %v", c.name, got, c.want)
|
| 80 | }
|
| 81 | }
|
| 82 | }
|
| 83 |
|
| 84 | func TestNormalizeErr(t *testing.T) {
|
| 85 | // The same server failure against different nodes/repos must collapse to one
|
| 86 | // key: the embedded receive-pack URL varies, the status code (the signal) does not.
|
| 87 | a := normalizeErr(`push: unexpected requesting "https://node3.cluster/repo-7/git-receive-pack" status code: 503`)
|
| 88 | b := normalizeErr(`push: unexpected requesting "https://node1.cluster/repo-42/git-receive-pack" status code: 503`)
|
| 89 | if a != b {
|
| 90 | t.Errorf("URL variance not collapsed:\n a=%q\n b=%q", a, b)
|
| 91 | }
|
| 92 | if !strings.Contains(a, "<url>") || !strings.Contains(a, "status code: 503") {
|
| 93 | t.Errorf("normalized dropped the signal: %q", a)
|
| 94 | }
|
| 95 |
|
| 96 | // Per-agent refs collapse too.
|
| 97 | r1 := normalizeErr("command error on refs/heads/run-a4: rejected")
|
| 98 | r2 := normalizeErr("command error on refs/heads/run-a17: rejected")
|
| 99 | if r1 != r2 || !strings.Contains(r1, "<ref>") {
|
| 100 | t.Errorf("ref variance not collapsed: %q vs %q", r1, r2)
|
| 101 | }
|
| 102 |
|
| 103 | // Truncation runs AFTER normalization: a long URL must not push the status
|
| 104 | // code past the cutoff.
|
| 105 | longURL := "https://node." + strings.Repeat("x", 400) + "/repo/git-receive-pack"
|
| 106 | got := normalizeErr(fmt.Sprintf(`push: unexpected requesting "%s" status code: 500`, longURL))
|
| 107 | if !strings.Contains(got, "status code: 500") {
|
| 108 | t.Errorf("status code cut off by truncation: %q", got)
|
| 109 | }
|
| 110 | if len(got) > maxErrMsgLen {
|
| 111 | t.Errorf("normalized message not truncated: len=%d", len(got))
|
| 112 | }
|
| 113 |
|
| 114 | if got := normalizeErr(""); got != "(no message)" {
|
| 115 | t.Errorf("empty message = %q, want (no message)", got)
|
| 116 | }
|
| 117 |
|
| 118 | // A message with no volatile parts is truncated but otherwise untouched.
|
| 119 | long := strings.Repeat("z", maxErrMsgLen+50)
|
| 120 | if got := normalizeErr(long); len(got) != maxErrMsgLen {
|
| 121 | t.Errorf("plain message not truncated to %d: got len=%d", maxErrMsgLen, len(got))
|
| 122 | }
|
| 123 | }
|
| 124 |
|
| 125 | func TestSummarizeAggregatesErrorMessages(t *testing.T) {
|
| 126 | window := 10 * time.Second
|
| 127 | samples := []sample{
|
| 128 | // Two push errors that normalize to the same 503 group (different URLs).
|
| 129 | {offset: 1 * time.Second, res: outcomeErr, msg: `push: unexpected requesting "https://n1/r1/git-receive-pack" status code: 503`},
|
| 130 | {offset: 2 * time.Second, res: outcomeErr, msg: `push: unexpected requesting "https://n2/r9/git-receive-pack" status code: 503`},
|
| 131 | // One local harness-bug error, distinct message (no volatile parts).
|
| 132 | {offset: 3 * time.Second, res: outcomeErr, msg: "commit: worktree: disk full"},
|
| 133 | // A successful push (no message) and a CAS (no message) must not appear.
|
| 134 | {offset: 4 * time.Second, dur: 10 * time.Millisecond, res: outcomeOK},
|
| 135 | {offset: 5 * time.Second, res: outcomeCAS},
|
| 136 | // A clone error goes to the clone bucket only.
|
| 137 | {offset: 6 * time.Second, res: outcomeErr, op: opClone, msg: `clone: unexpected requesting "https://n1/r1/git-upload-pack" status code: 500`},
|
| 138 | }
|
| 139 | r := summarize(samples, 8, "session", 1, 3, 0, window, "1-10 x 2048B")
|
| 140 |
|
| 141 | if len(r.ErrorMessages) != 2 {
|
| 142 | t.Fatalf("ErrorMessages = %+v, want 2 groups", r.ErrorMessages)
|
| 143 | }
|
| 144 | // Count-desc order: the 503 group (2) before the commit failure (1).
|
| 145 | if r.ErrorMessages[0].Count != 2 ||
|
| 146 | !strings.Contains(r.ErrorMessages[0].Message, "status code: 503") ||
|
| 147 | !strings.Contains(r.ErrorMessages[0].Message, "<url>") {
|
| 148 | t.Errorf("top push error = %+v, want count 2 / normalized 503", r.ErrorMessages[0])
|
| 149 | }
|
| 150 | if r.ErrorMessages[1].Count != 1 || r.ErrorMessages[1].Message != "commit: worktree: disk full" {
|
| 151 | t.Errorf("second push error = %+v, want the commit failure verbatim", r.ErrorMessages[1])
|
| 152 | }
|
| 153 |
|
| 154 | if len(r.CloneErrorMessages) != 1 || r.CloneErrorMessages[0].Count != 1 ||
|
| 155 | !strings.Contains(r.CloneErrorMessages[0].Message, "status code: 500") {
|
| 156 | t.Errorf("CloneErrorMessages = %+v, want one 500 group", r.CloneErrorMessages)
|
| 157 | }
|
| 158 |
|
| 159 | // A clean run must emit no lists (so omitempty fires in the JSON).
|
| 160 | clean := summarize([]sample{{offset: time.Second, dur: time.Millisecond, res: outcomeOK}}, 1, "branch", 1, 1, 0, window, "")
|
| 161 | if clean.ErrorMessages != nil || clean.CloneErrorMessages != nil {
|
| 162 | t.Errorf("clean run should have nil error lists, got %+v / %+v", clean.ErrorMessages, clean.CloneErrorMessages)
|
| 163 | }
|
| 164 | }
|
| 165 |
|
| 166 | func TestSummarizeCapsDistinctErrors(t *testing.T) {
|
| 167 | var samples []sample
|
| 168 | const distinct = 25
|
| 169 | for i := range distinct {
|
| 170 | // No volatile substrings, so each stays a distinct normalized key.
|
| 171 | samples = append(samples, sample{offset: time.Second, res: outcomeErr, msg: fmt.Sprintf("error variant %d", i)})
|
| 172 | }
|
| 173 | r := summarize(samples, 1, "branch", 1, 1, 0, 10*time.Second, "")
|
| 174 |
|
| 175 | // maxDistinctErrors real messages + one "(other errors)" overflow bucket.
|
| 176 | if len(r.ErrorMessages) != maxDistinctErrors+1 {
|
| 177 | t.Fatalf("groups = %d, want %d", len(r.ErrorMessages), maxDistinctErrors+1)
|
| 178 | }
|
| 179 | var overflow *errGroup
|
| 180 | for i := range r.ErrorMessages {
|
| 181 | if r.ErrorMessages[i].Message == "(other errors)" {
|
| 182 | overflow = &r.ErrorMessages[i]
|
| 183 | }
|
| 184 | }
|
| 185 | if overflow == nil {
|
| 186 | t.Fatal("no (other errors) overflow bucket")
|
| 187 | }
|
| 188 | if want := distinct - maxDistinctErrors; overflow.Count != want {
|
| 189 | t.Errorf("overflow count = %d, want %d", overflow.Count, want)
|
| 190 | }
|
| 191 | }
|
| 192 |
|
| 193 | // session strategy records clone (op=clone) and push (op=push) samples in one
|
| 194 | // slice; summarize must report them as separate metrics, not conflate them.
|
| 195 | func TestSummarizeSplitsCloneAndPush(t *testing.T) {
|
| 196 | warmup := 0 * time.Second
|
| 197 | window := 10 * time.Second
|
| 198 | samples := []sample{
|
| 199 | {offset: 1 * time.Second, dur: 200 * time.Millisecond, res: outcomeOK, op: opClone},
|
| 200 | {offset: 2 * time.Second, dur: 400 * time.Millisecond, res: outcomeOK, op: opClone},
|
| 201 | {offset: 3 * time.Second, res: outcomeErr, op: opClone}, // failed clone
|
| 202 | {offset: 4 * time.Second, dur: 30 * time.Millisecond, res: outcomeOK, op: opPush},
|
| 203 | {offset: 5 * time.Second, dur: 50 * time.Millisecond, res: outcomeOK, op: opPush},
|
| 204 | }
|
| 205 | r := summarize(samples, 8, "session", 1, 3, warmup, window, "1-10 x 2048B")
|
| 206 |
|
| 207 | if r.Pushes != 2 || r.OK != 2 {
|
| 208 | t.Fatalf("push side: Pushes=%d OK=%d, want 2/2 (clones excluded)", r.Pushes, r.OK)
|
| 209 | }
|
| 210 | if r.P50ms != 30 {
|
| 211 | t.Fatalf("push p50=%v, want 30 (clone durations must not leak in)", r.P50ms)
|
| 212 | }
|
| 213 | if r.Clones != 3 || r.CloneOK != 2 || r.CloneErrors != 1 {
|
| 214 | t.Fatalf("clone side: clones=%d ok=%d err=%d, want 3/2/1", r.Clones, r.CloneOK, r.CloneErrors)
|
| 215 | }
|
| 216 | if r.CloneP50ms != 200 {
|
| 217 | t.Fatalf("clone p50=%v, want 200", r.CloneP50ms)
|
| 218 | }
|
| 219 | }
|