166 lines
4.7 KiB
Go
166 lines
4.7 KiB
Go
// Command dashboard-loadtest runs a concurrent read-only HTTP load test against
|
|
// a deployed GL dashboard.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
type result struct {
|
|
Duration time.Duration
|
|
Requests int64
|
|
Errors int64
|
|
Non2xx int64
|
|
Latency []time.Duration
|
|
}
|
|
|
|
func main() {
|
|
baseURL := flag.String("url", "http://127.0.0.1:8080", "dashboard base URL")
|
|
paths := flag.String("paths", "/", "comma-separated request paths")
|
|
concurrency := flag.Int("concurrency", 20, "number of concurrent workers")
|
|
duration := flag.Duration("duration", 15*time.Second, "test duration")
|
|
requestTimeout := flag.Duration("request-timeout", 5*time.Second, "timeout for each request")
|
|
flag.Parse()
|
|
|
|
if *concurrency < 1 || *duration <= 0 || *requestTimeout <= 0 {
|
|
fmt.Fprintln(os.Stderr, "concurrency and durations must be positive")
|
|
os.Exit(2)
|
|
}
|
|
targets, err := targets(*baseURL, *paths)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(2)
|
|
}
|
|
|
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
transport.MaxIdleConns = *concurrency
|
|
transport.MaxIdleConnsPerHost = *concurrency
|
|
client := &http.Client{Transport: transport, Timeout: *requestTimeout}
|
|
defer transport.CloseIdleConnections()
|
|
|
|
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
ctx, cancel := context.WithTimeout(signalCtx, *duration)
|
|
defer cancel()
|
|
|
|
fmt.Printf("Dashboard load test: %d workers for %s against %s\n", *concurrency, *duration, *baseURL)
|
|
started := time.Now()
|
|
result := run(ctx, client, targets, *concurrency)
|
|
result.Duration = time.Since(started)
|
|
printResult(result)
|
|
if result.Errors > 0 || result.Non2xx > 0 {
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func targets(baseURL, pathList string) ([]string, error) {
|
|
base, err := url.Parse(baseURL)
|
|
if err != nil || base.Scheme == "" || base.Host == "" {
|
|
return nil, fmt.Errorf("invalid dashboard URL %q", baseURL)
|
|
}
|
|
paths := strings.Split(pathList, ",")
|
|
result := make([]string, 0, len(paths))
|
|
for _, value := range paths {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
reference, parseErr := url.Parse(value)
|
|
if parseErr != nil || reference.IsAbs() || !strings.HasPrefix(reference.Path, "/") {
|
|
return nil, fmt.Errorf("invalid request path %q", value)
|
|
}
|
|
result = append(result, base.ResolveReference(reference).String())
|
|
}
|
|
if len(result) == 0 {
|
|
return nil, errors.New("at least one request path is required")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func run(ctx context.Context, client *http.Client, targets []string, concurrency int) result {
|
|
var (
|
|
requests int64
|
|
failures int64
|
|
non2xx int64
|
|
sequence uint64
|
|
latency = make([]time.Duration, 0, concurrency*100)
|
|
lock sync.Mutex
|
|
workers sync.WaitGroup
|
|
)
|
|
workers.Add(concurrency)
|
|
for range concurrency {
|
|
go func() {
|
|
defer workers.Done()
|
|
for ctx.Err() == nil {
|
|
target := targets[(atomic.AddUint64(&sequence, 1)-1)%uint64(len(targets))]
|
|
request, err := http.NewRequestWithContext(context.WithoutCancel(ctx), http.MethodGet, target, nil)
|
|
if err != nil {
|
|
atomic.AddInt64(&failures, 1)
|
|
continue
|
|
}
|
|
started := time.Now()
|
|
response, err := client.Do(request)
|
|
elapsed := time.Since(started)
|
|
if err != nil {
|
|
atomic.AddInt64(&requests, 1)
|
|
atomic.AddInt64(&failures, 1)
|
|
continue
|
|
}
|
|
_, copyErr := io.Copy(io.Discard, io.LimitReader(response.Body, 2<<20))
|
|
closeErr := response.Body.Close()
|
|
atomic.AddInt64(&requests, 1)
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
atomic.AddInt64(&non2xx, 1)
|
|
}
|
|
if copyErr != nil || closeErr != nil {
|
|
atomic.AddInt64(&failures, 1)
|
|
}
|
|
lock.Lock()
|
|
latency = append(latency, elapsed)
|
|
lock.Unlock()
|
|
}
|
|
}()
|
|
}
|
|
workers.Wait()
|
|
return result{Requests: requests, Errors: failures, Non2xx: non2xx, Latency: latency}
|
|
}
|
|
|
|
func printResult(value result) {
|
|
seconds := value.Duration.Seconds()
|
|
requestsPerSecond := float64(value.Requests)
|
|
if seconds > 0 {
|
|
requestsPerSecond /= seconds
|
|
}
|
|
fmt.Printf("requests: %d\n", value.Requests)
|
|
fmt.Printf("throughput: %.1f req/s\n", requestsPerSecond)
|
|
fmt.Printf("latency: p50=%s p95=%s p99=%s\n", percentile(value.Latency, 50), percentile(value.Latency, 95), percentile(value.Latency, 99))
|
|
fmt.Printf("errors: %d transport, %d non-2xx\n", value.Errors, value.Non2xx)
|
|
}
|
|
|
|
func percentile(values []time.Duration, percent int) time.Duration {
|
|
if len(values) == 0 {
|
|
return 0
|
|
}
|
|
ordered := append([]time.Duration(nil), values...)
|
|
sort.Slice(ordered, func(left, right int) bool { return ordered[left] < ordered[right] })
|
|
index := (len(ordered)*percent + 99) / 100
|
|
if index < 1 {
|
|
index = 1
|
|
}
|
|
return ordered[index-1]
|
|
}
|