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()) }