173 lines
4.9 KiB
Go
173 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type connection struct {
|
|
body io.ReadCloser
|
|
cursor int64
|
|
}
|
|
|
|
func main() {
|
|
url := flag.String("url", "http://localhost:58080/api/v1/events", "SSE endpoint")
|
|
count := flag.Int("connections", 500, "simultaneous connections")
|
|
rate := flag.Int("open-rate", 40, "new connections per second")
|
|
hold := flag.Duration("hold", 5*time.Second, "hold after all connections are ready")
|
|
reconnects := flag.Int("reconnects", 50, "connections to reopen with Last-Event-ID")
|
|
flag.Parse()
|
|
token := strings.TrimSpace(os.Getenv("ACCESS_TOKEN"))
|
|
if token == "" || *count < 1 || *count > 5000 || *rate < 1 || *rate > 1000 || *reconnects < 0 || *reconnects > *count {
|
|
fatal("ACCESS_TOKEN and safe positive load parameters are required")
|
|
}
|
|
transport := &http.Transport{
|
|
DialContext: (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
|
MaxIdleConns: *count + *reconnects,
|
|
MaxIdleConnsPerHost: *count + *reconnects,
|
|
IdleConnTimeout: 30 * time.Second,
|
|
}
|
|
client := &http.Client{Transport: transport}
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*count/(*rate)+20)*time.Second)
|
|
defer cancel()
|
|
|
|
connections, err := openBatch(ctx, client, *url, token, *count, *rate, nil)
|
|
if err != nil {
|
|
fatal(err.Error())
|
|
}
|
|
if err = waitHealthy(connections, *hold); err != nil {
|
|
closeAll(connections)
|
|
fatal(err.Error())
|
|
}
|
|
cursors := make([]int64, 0, *reconnects)
|
|
for i := 0; i < *reconnects; i++ {
|
|
if connections[i].cursor < 1 {
|
|
closeAll(connections)
|
|
fatal("SSE fixture did not provide a positive resumable cursor")
|
|
}
|
|
cursors = append(cursors, connections[i].cursor)
|
|
}
|
|
closeAll(connections)
|
|
|
|
reconnectCtx, reconnectCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer reconnectCancel()
|
|
reopened, err := openBatch(reconnectCtx, client, *url, token, *reconnects, *rate, cursors)
|
|
if err != nil {
|
|
fatal("Last-Event-ID reconnect failed: " + err.Error())
|
|
}
|
|
closeAll(reopened)
|
|
fmt.Printf("SSE connection load passed: simultaneous=%d, open_rate=%d/s, held=%s, Last-Event-ID_reconnects=%d\n", *count, *rate, hold.String(), *reconnects)
|
|
}
|
|
|
|
func openBatch(ctx context.Context, client *http.Client, url, token string, count, rate int, cursors []int64) ([]connection, error) {
|
|
results := make([]connection, count)
|
|
errorsByIndex := make([]error, count)
|
|
var wg sync.WaitGroup
|
|
interval := time.Second / time.Duration(rate)
|
|
started := time.Now()
|
|
for i := 0; i < count; i++ {
|
|
target := started.Add(time.Duration(i) * interval)
|
|
if wait := time.Until(target); wait > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(wait):
|
|
}
|
|
}
|
|
wg.Add(1)
|
|
go func(index int) {
|
|
defer wg.Done()
|
|
cursor := int64(0)
|
|
if cursors != nil {
|
|
cursor = cursors[index]
|
|
}
|
|
results[index], errorsByIndex[index] = openOne(ctx, client, url, token, cursor)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
for i, err := range errorsByIndex {
|
|
if err != nil {
|
|
closeAll(results)
|
|
return nil, fmt.Errorf("connection %d: %w", i, err)
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
func openOne(ctx context.Context, client *http.Client, url, token string, cursor int64) (connection, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return connection{}, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Accept", "text/event-stream")
|
|
if cursor > 0 {
|
|
req.Header.Set("Last-Event-ID", strconv.FormatInt(cursor, 10))
|
|
}
|
|
response, err := client.Do(req)
|
|
if err != nil {
|
|
return connection{}, err
|
|
}
|
|
if response.StatusCode != http.StatusOK || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream") {
|
|
body, _ := io.ReadAll(io.LimitReader(response.Body, 512))
|
|
response.Body.Close()
|
|
return connection{}, fmt.Errorf("unexpected response %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
reader := bufio.NewReader(response.Body)
|
|
readCursor := int64(0)
|
|
for {
|
|
line, readErr := reader.ReadString('\n')
|
|
if readErr != nil {
|
|
response.Body.Close()
|
|
return connection{}, readErr
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "id:") {
|
|
readCursor, _ = strconv.ParseInt(strings.TrimSpace(strings.TrimPrefix(line, "id:")), 10, 64)
|
|
}
|
|
if line == "" {
|
|
break
|
|
}
|
|
}
|
|
return connection{body: response.Body, cursor: readCursor}, nil
|
|
}
|
|
|
|
func waitHealthy(items []connection, hold time.Duration) error {
|
|
if len(items) == 0 {
|
|
return errors.New("no SSE connections were opened")
|
|
}
|
|
timer := time.NewTimer(hold)
|
|
defer timer.Stop()
|
|
<-timer.C
|
|
for i, item := range items {
|
|
if item.body == nil || item.cursor < 1 {
|
|
return fmt.Errorf("connection %d is not healthy", i)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func closeAll(items []connection) {
|
|
for _, item := range items {
|
|
if item.body != nil {
|
|
_ = item.body.Close()
|
|
}
|
|
}
|
|
}
|
|
|
|
func fatal(message string) {
|
|
fmt.Fprintln(os.Stderr, message)
|
|
os.Exit(1)
|
|
}
|