Files
casino/pkg/ledger/load_test.go
drjones 038550b6ff perf: single-statement postings, marshal-once broadcast, client interpolation
Measured, then fixed, the three things that made a crowd impossible.

Ledger: Post issued three round trips per posting, so settlement scaled
in network latency rather than work. It is now two statements regardless
of leg count — settling 1000 winners went 844ms to 220ms. The lock and
the balance read must stay separate statements: a single statement, even
one whose CTE does FOR UPDATE, evaluates against a snapshot taken before
the locks are held, so concurrent transactions read stale balances and
money disappears. The conservation tests caught exactly that.

Broadcast: every connection marshalled its own copy, ~355us each. At any
real crowd that exceeds the tick interval by orders of magnitude. Frames
are now serialised once per broadcast and shared.

Feed: the player list is capped at 24 and carries no public keys, and
running rounds broadcast at 5Hz instead of 60Hz. Clients compute the
multiplier locally from the round start time, which the deterministic
curve makes exact. Frame size fell from 3.6KB to 1.8KB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:36:30 +00:00

228 lines
5.7 KiB
Go

package ledger_test
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/drjones/quantum-arcade/pkg/ledger"
)
// These measure throughput on the paths that decide whether the platform
// survives a crowd. They print numbers rather than asserting thresholds,
// because the numbers depend on the host — but the shape of the result is what
// matters, and a regression shows up immediately.
// Every bet debits the player and credits the house. All of them contend on
// the same house account row, which is the first thing to check: if that lock
// serialises the workload, no amount of hardware helps.
func TestThroughputContendedHouseAccount(t *testing.T) {
if testing.Short() {
t.Skip("load test")
}
l := ledger.New(testPool(t))
ctx := context.Background()
house, err := l.EnsurePlayer(ctx, uniqueKey(t, "house"))
if err != nil {
t.Fatal(err)
}
const players = 64
const perPlayer = 20
ids := make([]int64, players)
for i := range ids {
id, err := l.EnsurePlayer(ctx, uniqueKey(t, fmt.Sprintf("p%d", i)))
if err != nil {
t.Fatal(err)
}
if _, err := l.Deposit(ctx, id, 10_000_000); err != nil {
t.Fatal(err)
}
ids[i] = id
}
var ok, failed atomic.Int64
start := time.Now()
var wg sync.WaitGroup
for _, id := range ids {
wg.Add(1)
go func(id int64) {
defer wg.Done()
for i := 0; i < perPlayer; i++ {
_, err := l.Post(ctx, "bet", nil, []ledger.Posting{
{AccountID: id, AmountMsat: -1000},
{AccountID: house, AmountMsat: 1000},
})
if err != nil {
failed.Add(1)
} else {
ok.Add(1)
}
}
}(id)
}
wg.Wait()
elapsed := time.Since(start)
rate := float64(ok.Load()) / elapsed.Seconds()
t.Logf("contended (shared house row): %d bets in %v = %.0f bets/sec (%d failed)",
ok.Load(), elapsed.Round(time.Millisecond), rate, failed.Load())
t.Logf(" -> a 20s betting window absorbs about %.0f bets", rate*20)
}
// The same workload with the house side spread over several accounts, to
// isolate how much of the cost is lock contention rather than raw database
// throughput.
func TestThroughputShardedHouseAccount(t *testing.T) {
if testing.Short() {
t.Skip("load test")
}
l := ledger.New(testPool(t))
ctx := context.Background()
const shards = 16
shardIDs := make([]int64, shards)
for i := range shardIDs {
id, err := l.EnsurePlayer(ctx, uniqueKey(t, fmt.Sprintf("houseshard%d", i)))
if err != nil {
t.Fatal(err)
}
shardIDs[i] = id
}
const players = 64
const perPlayer = 20
ids := make([]int64, players)
for i := range ids {
id, err := l.EnsurePlayer(ctx, uniqueKey(t, fmt.Sprintf("sp%d", i)))
if err != nil {
t.Fatal(err)
}
if _, err := l.Deposit(ctx, id, 10_000_000); err != nil {
t.Fatal(err)
}
ids[i] = id
}
var ok, failed atomic.Int64
start := time.Now()
var wg sync.WaitGroup
for n, id := range ids {
wg.Add(1)
go func(n int, id int64) {
defer wg.Done()
for i := 0; i < perPlayer; i++ {
// Each player uses a fixed shard, the way a real sharded
// house account would be selected.
shard := shardIDs[n%shards]
_, err := l.Post(ctx, "bet", nil, []ledger.Posting{
{AccountID: id, AmountMsat: -1000},
{AccountID: shard, AmountMsat: 1000},
})
if err != nil {
failed.Add(1)
} else {
ok.Add(1)
}
}
}(n, id)
}
wg.Wait()
elapsed := time.Since(start)
rate := float64(ok.Load()) / elapsed.Seconds()
t.Logf("sharded (%d house rows): %d bets in %v = %.0f bets/sec (%d failed)",
shards, ok.Load(), elapsed.Round(time.Millisecond), rate, failed.Load())
t.Logf(" -> a 20s betting window absorbs about %.0f bets", rate*20)
}
// Settlement writes every payout for a round. At scale this is one large
// transaction, so its cost per posting is what decides how long a crowd waits
// between rounds.
func TestThroughputBatchSettlement(t *testing.T) {
if testing.Short() {
t.Skip("load test")
}
l := ledger.New(testPool(t))
ctx := context.Background()
house, _ := l.EnsurePlayer(ctx, uniqueKey(t, "settlehouse"))
if _, err := l.Deposit(ctx, house, 1_000_000_000); err != nil {
t.Fatal(err)
}
for _, size := range []int{10, 100, 500, 1000} {
winners := make([]int64, size)
for i := range winners {
id, err := l.EnsurePlayer(ctx, uniqueKey(t, fmt.Sprintf("w%d-%d", size, i)))
if err != nil {
t.Fatal(err)
}
winners[i] = id
}
postings := make([]ledger.Posting, 0, size+1)
for _, w := range winners {
postings = append(postings, ledger.Posting{AccountID: w, AmountMsat: 1000})
}
postings = append(postings,
ledger.Posting{AccountID: house, AmountMsat: -int64(size) * 1000})
start := time.Now()
if _, err := l.Post(ctx, "payout", nil, postings); err != nil {
t.Fatalf("settling %d winners: %v", size, err)
}
elapsed := time.Since(start)
t.Logf("settle %4d winners in one transaction: %8v (%.2fms per winner)",
size, elapsed.Round(time.Millisecond),
float64(elapsed.Microseconds())/1000/float64(size))
}
}
// Balance reads are the most frequent query in the system: every client polls
// after every round.
func TestThroughputBalanceReads(t *testing.T) {
if testing.Short() {
t.Skip("load test")
}
l := ledger.New(testPool(t))
ctx := context.Background()
id, _ := l.EnsurePlayer(ctx, uniqueKey(t, "reader"))
if _, err := l.Deposit(ctx, id, 1_000_000); err != nil {
t.Fatal(err)
}
const readers = 32
const each = 100
var ok atomic.Int64
start := time.Now()
var wg sync.WaitGroup
for i := 0; i < readers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < each; j++ {
if _, err := l.Balance(ctx, id); err == nil {
ok.Add(1)
}
}
}()
}
wg.Wait()
elapsed := time.Since(start)
t.Logf("balance reads: %d in %v = %.0f reads/sec",
ok.Load(), elapsed.Round(time.Millisecond),
float64(ok.Load())/elapsed.Seconds())
}