Viewing files at commit c10cefe7abe5Commit detailsView latest

Browse files
oakmark/stats_test.go 219 lines · 8.6 KB · go
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
}