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)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOpenOneAndBatchCarryAuthenticationAndCursor(t *testing.T) {
|
||||
var requests atomic.Int64
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
requests.Add(1)
|
||||
if request.Header.Get("Authorization") != "Bearer token-1" || request.Header.Get("Accept") != "text/event-stream" {
|
||||
t.Errorf("headers=%v", request.Header)
|
||||
}
|
||||
if request.URL.Query().Get("resume") == "yes" && request.Header.Get("Last-Event-ID") != "41" {
|
||||
t.Errorf("Last-Event-ID=%q", request.Header.Get("Last-Event-ID"))
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
_, _ = io.WriteString(writer, "id: 42\nevent: heartbeat\ndata: {}\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
item, err := openOne(context.Background(), server.Client(), server.URL+"?resume=yes", "token-1", 41)
|
||||
if err != nil || item.cursor != 42 || item.body == nil {
|
||||
t.Fatalf("item=%+v err=%v", item, err)
|
||||
}
|
||||
closeAll([]connection{item})
|
||||
items, err := openBatch(context.Background(), server.Client(), server.URL, "token-1", 2, 1000, nil)
|
||||
if err != nil || len(items) != 2 || requests.Load() != 3 {
|
||||
t.Fatalf("items=%d requests=%d err=%v", len(items), requests.Load(), err)
|
||||
}
|
||||
if err := waitHealthy(items, time.Millisecond); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closeAll(items)
|
||||
}
|
||||
|
||||
func TestSSEValidationFailures(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = io.WriteString(writer, "denied")
|
||||
}))
|
||||
defer server.Close()
|
||||
if _, err := openOne(context.Background(), server.Client(), server.URL, "bad", 0); err == nil || !strings.Contains(err.Error(), "unexpected response 401") {
|
||||
t.Fatalf("unexpected response error=%v", err)
|
||||
}
|
||||
if err := waitHealthy(nil, 0); err == nil {
|
||||
t.Fatal("empty connection set accepted")
|
||||
}
|
||||
if err := waitHealthy([]connection{{}}, 0); err == nil || !strings.Contains(err.Error(), "not healthy") {
|
||||
t.Fatalf("unhealthy error=%v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := openBatch(ctx, server.Client(), server.URL, "token", 2, 1, nil); err == nil {
|
||||
t.Fatal("cancelled batch unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user