chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user