1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// loadtest replays a traffic scenario against an ermite server and reports
4// throughput and latency per request, while holding open the live updates
5// streams that the pages of real visitors keep.
6//
7// go run ./scripts/loadtest -url http://127.0.0.1:7418 -scenario home
8// go run ./scripts/loadtest -scenario repo -var repo=neferwl -rate 500
9//
10// Without -rate it runs closed-loop: -c clients send requests back to back,
11// which measures the maximum throughput. With -rate it runs open-loop: it
12// sends that many requests per second whatever the latency, which measures
13// the latency at a target load; a request that finds no free client is
14// counted as missed. See scripts/loadtest/README.md.
15package main
16
17import (
18 "context"
19 "encoding/json"
20 "flag"
21 "fmt"
22 "math"
23 "os"
24 "os/signal"
25 "strings"
26 "time"
27)
28
29func (c config) validate() error {
30 switch {
31 case c.clients < 1:
32 return fmt.Errorf("-c must be at least 1, got %d", c.clients)
33 case c.duration <= 0:
34 return fmt.Errorf("-d must be positive, got %v", c.duration)
35 case c.warmup < 0:
36 return fmt.Errorf("-warmup must not be negative, got %v", c.warmup)
37 case !(c.streams >= 0) || math.IsInf(c.streams, 0):
38 return fmt.Errorf("-streams must be a finite number >= 0, got %v", c.streams)
39 case !(c.rate >= 0) || math.IsInf(c.rate, 0):
40 return fmt.Errorf("-rate must be a finite number >= 0, got %v", c.rate)
41 case c.rate > 1e6:
42 return fmt.Errorf("-rate is at most 1000000, got %v", c.rate)
43 }
44 return nil
45}
46
47type varsFlag map[string]string
48
49func (v varsFlag) String() string { return fmt.Sprint(map[string]string(v)) }
50
51func (v varsFlag) Set(s string) error {
52 k, val, ok := strings.Cut(s, "=")
53 if !ok || k == "" {
54 return fmt.Errorf("want name=value, got %q", s)
55 }
56 v[k] = val
57 return nil
58}
59
60func main() {
61 cfg := config{vars: varsFlag{}}
62 flag.StringVar(&cfg.base, "url", "http://127.0.0.1:7418", "base URL of the server")
63 flag.StringVar(&cfg.scenario, "scenario", "home", "built-in scenario name, or path to a .json file")
64 flag.Var(cfg.vars, "var", "scenario variable name=value, repeatable")
65 flag.IntVar(&cfg.clients, "c", 32, "concurrent clients")
66 flag.Float64Var(&cfg.rate, "rate", 0, "requests per second (open-loop); 0 runs closed-loop")
67 flag.DurationVar(&cfg.duration, "d", 10*time.Second, "measured duration")
68 flag.DurationVar(&cfg.warmup, "warmup", 2*time.Second, "unmeasured warm-up before the run")
69 flag.Float64Var(&cfg.streams, "streams", 1, "multiplier of the stream counts of the scenario; 0 opens none")
70 flag.StringVar(&cfg.token, "token", "", "bearer token sent with every request (signed-in pages)")
71 flag.BoolVar(&cfg.gzip, "gzip", true, "ask for compressed responses, as browsers do")
72 flag.StringVar(&cfg.jsonOut, "json", "", "also write the report as JSON to this file")
73 flag.Parse()
74
75 if err := cfg.validate(); err != nil {
76 fmt.Fprintln(os.Stderr, "loadtest:", err)
77 os.Exit(2)
78 }
79 sc, err := loadScenario(cfg.scenario, cfg.vars)
80 if err != nil {
81 fmt.Fprintln(os.Stderr, "loadtest:", err)
82 os.Exit(2)
83 }
84 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
85 defer stop()
86
87 rep, err := run(ctx, cfg, sc)
88 if err != nil {
89 fmt.Fprintln(os.Stderr, "loadtest:", err)
90 os.Exit(1)
91 }
92 if err := rep.print(os.Stdout); err != nil {
93 fmt.Fprintln(os.Stderr, "loadtest:", err)
94 os.Exit(1)
95 }
96 if cfg.jsonOut != "" {
97 data, _ := json.MarshalIndent(rep, "", " ")
98 if err := os.WriteFile(cfg.jsonOut, append(data, '\n'), 0o644); err != nil {
99 fmt.Fprintln(os.Stderr, "loadtest:", err)
100 os.Exit(1)
101 }
102 }
103 if rep.Failed() {
104 os.Exit(1)
105 }
106}