This commit is contained in:
2026-09-04 17:43:46 -05:00
parent 5e2a957e59
commit 3025addefd
4 changed files with 10332 additions and 8 deletions
+28 -2
View File
@@ -232,11 +232,37 @@ go test -v .
### Load Testing ### Load Testing
The load-test utility sends concurrent GET requests and can distribute them
across pages with `-pageMix`. Each entry uses the format
`page-or-range:weight`; range weights are distributed evenly across the pages
in that range. Weights are normalized automatically and do not need to total
100.
```bash ```bash
cd test go run ./test/loadtest.go \
go run loadtest.go -n 100 -c 10 -url "http://localhost:8080/api/playeradvancedstats?page=1&pageSize=20" -log results.log -url "http://localhost:8081/api/playertotals?page=1&pageSize=50" \
-n 500 \
-c 20 \
-pageMix "1-3:60,4-10:30,11-20:10" \
-seed 42 \
-log ./test/results.log
``` ```
In this example, approximately 60% of requests target pages 1 through 3,
30% target pages 4 through 10, and 10% target pages 11 through 20. The
`-seed` flag makes page selection reproducible; omit it to use a time-based
seed. Without `-pageMix`, the URL is sent unchanged as in the original
load-test behavior.
The utility prints aggregate results and per-page counts, failures, and
average successful response time. Invalid page ranges, non-positive weights,
and overlapping ranges are rejected before requests are sent.
Load tests should normally target a controlled environment. NGINX caches each
full API URI for 30 seconds, and the API rate limiter allows 20 requests per
minute per client IP on each API instance. These settings can make a test
measure cache hits or rate limiting rather than database-read performance.
## Tech Stack ## Tech Stack
| Component | Technology | | Component | Technology |
+178 -6
View File
@@ -5,8 +5,14 @@ import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log" "log"
"math"
"math/rand"
"net/http" "net/http"
"net/url"
"os" "os"
"sort"
"strconv"
"strings"
"sync" "sync"
"time" "time"
) )
@@ -17,6 +23,96 @@ type Response struct {
Body string Body string
Duration time.Duration Duration time.Duration
Error error Error error
Page int
}
type pageBucket struct {
page int
weight float64
}
func parsePageMix(spec string) ([]pageBucket, error) {
if strings.TrimSpace(spec) == "" {
return nil, nil
}
seenPages := make(map[int]bool)
var buckets []pageBucket
for _, rawEntry := range strings.Split(spec, ",") {
entry := strings.TrimSpace(rawEntry)
parts := strings.Split(entry, ":")
if len(parts) != 2 {
return nil, fmt.Errorf("invalid page mix entry %q: expected pages:weight", entry)
}
pageParts := strings.Split(strings.TrimSpace(parts[0]), "-")
if len(pageParts) > 2 {
return nil, fmt.Errorf("invalid page range %q", parts[0])
}
firstPage, err := strconv.Atoi(strings.TrimSpace(pageParts[0]))
if err != nil || firstPage < 1 {
return nil, fmt.Errorf("invalid first page %q", pageParts[0])
}
lastPage := firstPage
if len(pageParts) == 2 {
lastPage, err = strconv.Atoi(strings.TrimSpace(pageParts[1]))
if err != nil || lastPage < firstPage {
return nil, fmt.Errorf("invalid page range %q", parts[0])
}
}
weight, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
if err != nil || weight <= 0 || math.IsNaN(weight) || math.IsInf(weight, 0) {
return nil, fmt.Errorf("invalid weight %q", parts[1])
}
pageCount := lastPage - firstPage + 1
weightPerPage := weight / float64(pageCount)
for page := firstPage; page <= lastPage; page++ {
if seenPages[page] {
return nil, fmt.Errorf("page %d appears in overlapping ranges", page)
}
seenPages[page] = true
buckets = append(buckets, pageBucket{page: page, weight: weightPerPage})
}
}
return buckets, nil
}
func selectPages(buckets []pageBucket, count int, seed int64) []int {
pages := make([]int, count)
if len(buckets) == 0 || count == 0 {
return pages
}
totalWeight := 0.0
for _, bucket := range buckets {
totalWeight += bucket.weight
}
rng := rand.New(rand.NewSource(seed))
for i := range pages {
target := rng.Float64() * totalWeight
cumulativeWeight := 0.0
for _, bucket := range buckets {
cumulativeWeight += bucket.weight
if target < cumulativeWeight {
pages[i] = bucket.page
break
}
}
}
return pages
}
func urlForPage(base url.URL, page int) string {
query := base.Query()
query.Set("page", strconv.Itoa(page))
base.RawQuery = query.Encode()
return base.String()
} }
func main() { func main() {
@@ -26,8 +122,37 @@ func main() {
endpoint := flag.String("url", "http://localhost:8080/api/player/advanced", "API endpoint to test") endpoint := flag.String("url", "http://localhost:8080/api/player/advanced", "API endpoint to test")
logFile := flag.String("log", "loadtest.log", "Log file path") logFile := flag.String("log", "loadtest.log", "Log file path")
apiKey := flag.String("key", "", "API key for x-api-key header") apiKey := flag.String("key", "", "API key for x-api-key header")
pageMix := flag.String("pageMix", "", "Weighted page mix, for example 1-3:60,4-10:30,11-20:10")
seed := flag.Int64("seed", -1, "Random seed for page selection (-1 uses the current time)")
flag.Parse() flag.Parse()
if *numRequests <= 0 {
log.Fatal("Number of requests must be greater than zero")
}
if *concurrency <= 0 {
log.Fatal("Concurrency must be greater than zero")
}
pageBuckets, err := parsePageMix(*pageMix)
if err != nil {
log.Fatalf("Invalid page mix: %v", err)
}
var baseURL url.URL
if len(pageBuckets) > 0 {
parsedURL, parseErr := url.Parse(*endpoint)
if parseErr != nil {
log.Fatalf("Invalid endpoint URL: %v", parseErr)
}
baseURL = *parsedURL
}
selectionSeed := *seed
if selectionSeed == -1 {
selectionSeed = time.Now().UnixNano()
}
selectedPages := selectPages(pageBuckets, *numRequests, selectionSeed)
// Setup logging // Setup logging
f, err := os.OpenFile(*logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) f, err := os.OpenFile(*logFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil { if err != nil {
@@ -66,12 +191,17 @@ func main() {
Duration: 0, Duration: 0,
Error: nil, Error: nil,
} }
requestEndpoint := *endpoint
if len(selectedPages) > 0 {
result.Page = selectedPages[id]
requestEndpoint = urlForPage(baseURL, result.Page)
}
req, err := http.NewRequest("GET", *endpoint, nil) req, err := http.NewRequest("GET", requestEndpoint, nil)
if err != nil { if err != nil {
result.Duration = time.Since(start) result.Duration = time.Since(start)
result.Error = err result.Error = err
log.Printf("Request %d failed to create request: %v", id, err) log.Printf("Request %d (page %d) failed to create request: %v", id, result.Page, err)
results <- result results <- result
return return
} }
@@ -88,7 +218,7 @@ func main() {
result.Error = err result.Error = err
if err != nil { if err != nil {
log.Printf("Request %d failed: %v", id, err) log.Printf("Request %d (page %d) failed: %v", id, result.Page, err)
results <- result results <- result
return return
} }
@@ -96,7 +226,7 @@ func main() {
defer resp.Body.Close() defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body) body, err := ioutil.ReadAll(resp.Body)
if err != nil { if err != nil {
log.Printf("Failed to read response body: %v", err) log.Printf("Request %d (page %d) failed to read response body: %v", id, result.Page, err)
result.Error = err result.Error = err
results <- result results <- result
return return
@@ -107,7 +237,7 @@ func main() {
results <- result results <- result
// Log request details // Log request details
log.Printf("Request %d: Status=%d, Time=%v", id, resp.StatusCode, duration) log.Printf("Request %d: Page=%d, Status=%d, Time=%v", id, result.Page, resp.StatusCode, duration)
}(i) }(i)
} }
@@ -120,11 +250,27 @@ func main() {
// Process results // Process results
var successCount, errorCount int var successCount, errorCount int
var totalDuration time.Duration var totalDuration time.Duration
pageResults := make(map[int]*pageResult)
for result := range results { for result := range results {
if result.Page > 0 {
stats, ok := pageResults[result.Page]
if !ok {
stats = &pageResult{}
pageResults[result.Page] = stats
}
stats.requests++
if result.Error != nil || result.StatusCode != http.StatusOK {
stats.failures++
} else {
stats.successes++
stats.totalDuration += result.Duration
}
}
if result.Error != nil { if result.Error != nil {
errorCount++ errorCount++
} else if result.StatusCode == 200 { } else if result.StatusCode == http.StatusOK {
successCount++ successCount++
totalDuration += result.Duration totalDuration += result.Duration
} else { } else {
@@ -148,4 +294,30 @@ func main() {
fmt.Printf("Total Time: %v\n", totalTime) fmt.Printf("Total Time: %v\n", totalTime)
fmt.Printf("Average Response Time: %v\n", avgDuration) fmt.Printf("Average Response Time: %v\n", avgDuration)
fmt.Printf("Requests Per Second: %.2f\n", requestsPerSecond) fmt.Printf("Requests Per Second: %.2f\n", requestsPerSecond)
if len(pageResults) > 0 {
pages := make([]int, 0, len(pageResults))
for page := range pageResults {
pages = append(pages, page)
}
sort.Ints(pages)
fmt.Printf("\nPer-page Results:\n")
for _, page := range pages {
stats := pageResults[page]
pageAverage := time.Duration(0)
if stats.successes > 0 {
pageAverage = stats.totalDuration / time.Duration(stats.successes)
}
fmt.Printf("Page %d: Requests=%d, Successful=%d, Failed=%d, Average Response Time=%v\n",
page, stats.requests, stats.successes, stats.failures, pageAverage)
}
}
}
type pageResult struct {
requests int
successes int
failures int
totalDuration time.Duration
} }
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"net/url"
"reflect"
"testing"
)
func TestParsePageMixDistributesRangeWeights(t *testing.T) {
buckets, err := parsePageMix("1-3:60,4-10:30,11-20:10")
if err != nil {
t.Fatalf("parsePageMix returned an error: %v", err)
}
if len(buckets) != 20 {
t.Fatalf("expected 20 page buckets, got %d", len(buckets))
}
for page := 1; page <= 20; page++ {
bucket := buckets[page-1]
if bucket.page != page {
t.Errorf("bucket %d has page %d", page-1, bucket.page)
}
expectedWeight := 1.0
if page <= 3 {
expectedWeight = 20
} else if page <= 10 {
expectedWeight = 30.0 / 7
}
if bucket.weight != expectedWeight {
t.Errorf("page %d has weight %v, want %v", page, bucket.weight, expectedWeight)
}
}
}
func TestParsePageMixRejectsInvalidSpecifications(t *testing.T) {
invalidSpecs := []string{
"1-3",
"0:1",
"3-1:1",
"1-3:0",
"letters:1",
"1:1,1:2",
}
for _, spec := range invalidSpecs {
t.Run(spec, func(t *testing.T) {
if _, err := parsePageMix(spec); err == nil {
t.Fatalf("parsePageMix(%q) returned nil error", spec)
}
})
}
}
func TestSelectPagesUsesConfiguredWeights(t *testing.T) {
buckets, err := parsePageMix("1-3:60,4-10:30,11-20:10")
if err != nil {
t.Fatalf("parsePageMix returned an error: %v", err)
}
pages := selectPages(buckets, 10000, 42)
if len(pages) != 10000 {
t.Fatalf("expected 10000 selected pages, got %d", len(pages))
}
counts := make(map[int]int)
for _, page := range pages {
counts[page]++
}
assertCountNear(t, "pages 1-3", counts[1]+counts[2]+counts[3], 6000, 150)
assertCountNear(t, "pages 4-10", sumPageCounts(counts, 4, 10), 3000, 150)
assertCountNear(t, "pages 11-20", sumPageCounts(counts, 11, 20), 1000, 100)
}
func TestSelectPagesIsReproducibleWithSeed(t *testing.T) {
buckets, err := parsePageMix("1:3,2:1")
if err != nil {
t.Fatalf("parsePageMix returned an error: %v", err)
}
first := selectPages(buckets, 20, 42)
second := selectPages(buckets, 20, 42)
if !reflect.DeepEqual(first, second) {
t.Fatalf("page selections differ for the same seed: %v != %v", first, second)
}
}
func TestURLForPageReplacesOnlyPageParameter(t *testing.T) {
base, err := url.Parse("https://example.test/api/playertotals?page=1&pageSize=50&season=2025")
if err != nil {
t.Fatalf("url.Parse returned an error: %v", err)
}
requestURL, err := url.Parse(urlForPage(*base, 7))
if err != nil {
t.Fatalf("url.Parse returned an error: %v", err)
}
query := requestURL.Query()
if query.Get("page") != "7" {
t.Errorf("page query parameter = %q, want 7", query.Get("page"))
}
if query.Get("pageSize") != "50" {
t.Errorf("pageSize query parameter = %q, want 50", query.Get("pageSize"))
}
if query.Get("season") != "2025" {
t.Errorf("season query parameter = %q, want 2025", query.Get("season"))
}
}
func assertCountNear(t *testing.T, label string, actual, expected, tolerance int) {
t.Helper()
if actual < expected-tolerance || actual > expected+tolerance {
t.Errorf("%s count = %d, want %d +/- %d", label, actual, expected, tolerance)
}
}
func sumPageCounts(counts map[int]int, firstPage, lastPage int) int {
total := 0
for page := firstPage; page <= lastPage; page++ {
total += counts[page]
}
return total
}
+10000
View File
File diff suppressed because it is too large Load Diff