mirror of
https://github.com/nprasad2077/NBA_Go.git
synced 2026-09-22 22:15:13 +00:00
final
This commit is contained in:
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user