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>
This commit is contained in:
153
cmd/loadtest/main.go
Normal file
153
cmd/loadtest/main.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user