Files
casino/cmd/loadtest/main.go
drjones 3bdb518f9c feat: refund abandoned rounds; full-journey and capacity tests
Fixes the money bug flagged earlier. When an instance died mid-round its
players had already been debited, so their stakes sat with the house:
balanced books, quietly robbed players. Every instance now sweeps for
unresolved rounds and refunds them.

Such a round is marked void, not settled. The schema caught this: the
reveal_is_complete constraint requires a settled round to publish its
seed, and an abandoned round has no outcome to reveal. Void is a distinct
state with its own column and a check that the two are exclusive.
Claiming happens before money moves, so concurrent reconcilers on
different instances refund exactly once.

Adds TestFullPlayerJourney: sign-in with no account, fund, scratch, bet
with an auto target, settle, verify the round independently, check the
ledger history is continuous, transfer to a friend, and confirm the books
still sum to zero. It asserts against the ledger rather than the API's
own summary.

Adds cmd/loadtest. One instance on 4 cores held 25,000 concurrent
websocket connections with zero failures at 586MB RSS, about 26KB per
connection, with the load generator competing for the same CPU.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:18:29 +00:00

154 lines
4.1 KiB
Go

// Command loadtest measures how many concurrent players an instance holds.
//
// It opens real WebSocket connections and, optionally, places real bets, then
// reports connection success, frame delivery, and latency. The point is to
// produce numbers rather than adjectives: run it against a candidate machine
// and read the ceiling off the output.
//
// go run ./cmd/loadtest -conns 2000 -addr localhost:8080
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"runtime"
"sort"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
func main() {
var (
addr = flag.String("addr", "localhost:8080", "instance to load")
conns = flag.Int("conns", 500, "concurrent websocket connections")
duration = flag.Duration("duration", 20*time.Second, "how long to hold them")
game = flag.String("game", "rocket", "game room to join")
ramp = flag.Duration("ramp", 5*time.Second, "time to open all connections")
)
flag.Parse()
ctx, cancel := context.WithTimeout(context.Background(), *duration+*ramp+30*time.Second)
defer cancel()
var (
connected atomic.Int64
failed atomic.Int64
frames atomic.Int64
bytesRecv atomic.Int64
dialMu sync.Mutex
dialTimes []time.Duration
)
fmt.Printf("opening %d connections to %s over %v\n", *conns, *addr, *ramp)
start := time.Now()
// Stagger dialling: slamming every connection open at once measures the
// accept backlog rather than the steady state anyone actually runs at.
gap := *ramp / time.Duration(max(1, *conns))
var wg sync.WaitGroup
for i := 0; i < *conns; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
time.Sleep(time.Duration(i) * gap)
dialStart := time.Now()
conn, _, err := websocket.Dial(ctx,
fmt.Sprintf("ws://%s/ws/%s", *addr, *game), nil)
if err != nil {
failed.Add(1)
return
}
took := time.Since(dialStart)
defer conn.CloseNow()
connected.Add(1)
dialMu.Lock()
dialTimes = append(dialTimes, took)
dialMu.Unlock()
// Read until the run ends. A client that stops reading is exactly
// the slow-subscriber case the server has to survive, but here we
// want the healthy path.
readCtx, stop := context.WithTimeout(ctx, *duration)
defer stop()
for {
_, data, err := conn.Read(readCtx)
if err != nil {
return
}
frames.Add(1)
bytesRecv.Add(int64(len(data)))
}
}(i)
}
// Report progress while the run is in flight.
done := make(chan struct{})
go func() {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf(" t+%-5s connected=%-6d failed=%-5d frames=%-8d client heap=%dMB\n",
time.Since(start).Round(time.Second),
connected.Load(), failed.Load(), frames.Load(),
m.Alloc/1024/1024)
}
}
}()
wg.Wait()
close(done)
elapsed := time.Since(start)
sort.Slice(dialTimes, func(i, j int) bool { return dialTimes[i] < dialTimes[j] })
fmt.Println()
fmt.Println("results")
fmt.Printf(" connections attempted : %d\n", *conns)
fmt.Printf(" connected : %d\n", connected.Load())
fmt.Printf(" failed : %d\n", failed.Load())
if len(dialTimes) > 0 {
fmt.Printf(" dial p50 / p99 / max : %v / %v / %v\n",
dialTimes[len(dialTimes)/2].Round(time.Millisecond),
dialTimes[len(dialTimes)*99/100].Round(time.Millisecond),
dialTimes[len(dialTimes)-1].Round(time.Millisecond))
}
fmt.Printf(" frames received : %d\n", frames.Load())
fmt.Printf(" bytes received : %.1f MB\n", float64(bytesRecv.Load())/1e6)
if connected.Load() > 0 {
fmt.Printf(" frames per connection : %.1f\n",
float64(frames.Load())/float64(connected.Load()))
fmt.Printf(" server egress : %.2f MB/s\n",
float64(bytesRecv.Load())/1e6/elapsed.Seconds())
}
if failed.Load() > 0 {
fmt.Fprintf(os.Stderr, "\n%d connections were refused: the ceiling is at or below %d\n",
failed.Load(), *conns)
os.Exit(1)
}
log.Printf("held %d concurrent connections for %v with no failures",
connected.Load(), duration)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}