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:
@@ -118,6 +118,10 @@ make cover # coverage per package
|
|||||||
make db-reset # wipe the ledger; it is append-only and accumulates
|
make db-reset # wipe the ledger; it is append-only and accumulates
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Measured capacity: **25,000 concurrent connections on one 4-core instance**,
|
||||||
|
zero failures, 586MB RSS. Bet throughput is the real ceiling at ~230/sec —
|
||||||
|
see [docs/SCALING.md](docs/SCALING.md).
|
||||||
|
|
||||||
Coverage sits around 85%, and the tests found and pinned three real money
|
Coverage sits around 85%, and the tests found and pinned three real money
|
||||||
bugs: a posting set that minted 18 quintillion millisatoshis by wrapping the
|
bugs: a posting set that minted 18 quintillion millisatoshis by wrapping the
|
||||||
zero-sum check, a crash point that overflowed negative at the rarest seed, and
|
zero-sum check, a crash point that overflowed negative at the rarest seed, and
|
||||||
@@ -155,6 +159,7 @@ Not yet built:
|
|||||||
exist; the node integration does not. The dev faucet stands in for now.
|
exist; the node integration does not. The dev faucet stands in for now.
|
||||||
- Tournaments and scheduled events
|
- Tournaments and scheduled events
|
||||||
- Operator dashboard
|
- Operator dashboard
|
||||||
|
- In-memory bet reservation, which is what would lift the ~230 bets/sec ceiling
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
|
|||||||
@@ -342,3 +342,235 @@ func TestAutoCashOutThroughTheAPI(t *testing.T) {
|
|||||||
t.Fatal("could not place an auto cash-out bet within 90s")
|
t.Fatal("could not place an auto cash-out bet within 90s")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The complete journey a real player takes, in one test: arrive with no
|
||||||
|
// account, get funded, play both games, watch the ledger explain every change,
|
||||||
|
// move sats to a friend, and verify a round independently.
|
||||||
|
//
|
||||||
|
// Each step asserts against the ledger rather than against the API's own
|
||||||
|
// summary, so a bug that reports success while losing money fails here.
|
||||||
|
func TestFullPlayerJourney(t *testing.T) {
|
||||||
|
alice := newClient(t)
|
||||||
|
bob := newClient(t)
|
||||||
|
|
||||||
|
// 1. Arrive. No account exists; a keypair is the whole sign-up.
|
||||||
|
alice.signIn("alice")
|
||||||
|
bob.signIn("bob")
|
||||||
|
|
||||||
|
var bal struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
alice.do("GET", "/api/balance", nil, &bal)
|
||||||
|
if bal.BalanceMsat != 0 {
|
||||||
|
t.Fatalf("a brand new player started with %d msat", bal.BalanceMsat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get funded.
|
||||||
|
const funded = 50_000_000
|
||||||
|
if got := alice.fund(funded); got != funded {
|
||||||
|
t.Fatalf("balance after funding = %d, want %d", got, funded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Scratch a ticket. The balance must move by exactly stake and payout.
|
||||||
|
var sc struct {
|
||||||
|
Outcome struct {
|
||||||
|
TierName string `json:"tier_name"`
|
||||||
|
PayoutMsat int64 `json:"payout_msat"`
|
||||||
|
Cells []int `json:"cells"`
|
||||||
|
} `json:"outcome"`
|
||||||
|
Proof struct {
|
||||||
|
Commitment string `json:"commitment"`
|
||||||
|
ServerSeed string `json:"server_seed"`
|
||||||
|
} `json:"proof"`
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
const scratchStake = 1_000_000
|
||||||
|
if code := alice.do("POST", "/api/scratch/play",
|
||||||
|
map[string]any{"ticket_id": "nebula-nine", "stake_msat": scratchStake}, &sc); code != 200 {
|
||||||
|
t.Fatalf("scratch play returned %d", code)
|
||||||
|
}
|
||||||
|
wantAfterScratch := int64(funded) - scratchStake + sc.Outcome.PayoutMsat
|
||||||
|
if sc.BalanceMsat != wantAfterScratch {
|
||||||
|
t.Fatalf("balance after scratch = %d, want %d", sc.BalanceMsat, wantAfterScratch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scratch proof must verify against its own seed.
|
||||||
|
seed, err := hex.DecodeString(sc.Proof.ServerSeed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(seed)
|
||||||
|
if hex.EncodeToString(sum[:]) != sc.Proof.Commitment {
|
||||||
|
t.Fatal("scratch proof does not verify against its own commitment")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Play a crash round with an auto cash-out target.
|
||||||
|
const stake = 2_000_000
|
||||||
|
var roundID int64
|
||||||
|
beforeRound := sc.BalanceMsat
|
||||||
|
|
||||||
|
deadline := time.Now().Add(90 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && roundID == 0 {
|
||||||
|
var games struct {
|
||||||
|
Rooms []struct {
|
||||||
|
RoundID int64 `json:"round_id"`
|
||||||
|
Game string `json:"game"`
|
||||||
|
State string `json:"state"`
|
||||||
|
} `json:"rooms"`
|
||||||
|
}
|
||||||
|
alice.do("GET", "/api/games", nil, &games)
|
||||||
|
for _, rm := range games.Rooms {
|
||||||
|
if rm.Game != "rocket" || rm.State != "betting_open" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var res struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
if code := alice.do("POST", "/api/bet", map[string]any{
|
||||||
|
"game": "rocket", "stake_msat": stake,
|
||||||
|
"auto_cashout": 1.5, "nickname": "alice",
|
||||||
|
}, &res); code == 200 {
|
||||||
|
roundID = rm.RoundID
|
||||||
|
// The stake must leave immediately, not at settlement.
|
||||||
|
if res.BalanceMsat != beforeRound-stake {
|
||||||
|
t.Fatalf("balance after bet = %d, want %d",
|
||||||
|
res.BalanceMsat, beforeRound-stake)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if roundID == 0 {
|
||||||
|
time.Sleep(400 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if roundID == 0 {
|
||||||
|
t.Fatal("could not join a round within 90s")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Wait for settlement and check the outcome is consistent.
|
||||||
|
var proof struct {
|
||||||
|
Commitment string `json:"commitment"`
|
||||||
|
ServerSeed string `json:"server_seed"`
|
||||||
|
ClientSeed string `json:"client_seed"`
|
||||||
|
Nonce int64 `json:"nonce"`
|
||||||
|
CrashPoint *int64 `json:"crash_point"`
|
||||||
|
Participants []string `json:"participants"`
|
||||||
|
}
|
||||||
|
settled := false
|
||||||
|
deadline = time.Now().Add(120 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if code := alice.do("GET", "/api/verify/"+itoa(roundID), nil, &proof); code == 200 {
|
||||||
|
settled = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !settled {
|
||||||
|
t.Fatal("the round never settled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verify the round independently, the way the client does.
|
||||||
|
roundSeed, err := hex.DecodeString(proof.ServerSeed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rs := sha256.Sum256(roundSeed)
|
||||||
|
if hex.EncodeToString(rs[:]) != proof.Commitment {
|
||||||
|
t.Fatal("settled round does not match its published commitment")
|
||||||
|
}
|
||||||
|
if proof.CrashPoint == nil {
|
||||||
|
t.Fatal("a settled round published no crash point")
|
||||||
|
}
|
||||||
|
crash := float64(*proof.CrashPoint) / 4294967296.0
|
||||||
|
|
||||||
|
// 7. Balance must reflect the outcome exactly: paid at 1.5x if the round
|
||||||
|
// reached the target, nothing otherwise.
|
||||||
|
var after struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
// Settlement posts a moment after the reveal; poll briefly.
|
||||||
|
wantWin := beforeRound - stake + stake*3/2
|
||||||
|
wantLose := beforeRound - stake
|
||||||
|
ok := false
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
alice.do("GET", "/api/balance", nil, &after)
|
||||||
|
if after.BalanceMsat == wantWin || after.BalanceMsat == wantLose {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("balance %d is neither the win (%d) nor the loss (%d) outcome",
|
||||||
|
after.BalanceMsat, wantWin, wantLose)
|
||||||
|
}
|
||||||
|
if crash >= 1.5 && after.BalanceMsat != wantWin {
|
||||||
|
t.Fatalf("round crashed at %.2fx, above the 1.50x target, but balance is %d not %d",
|
||||||
|
crash, after.BalanceMsat, wantWin)
|
||||||
|
}
|
||||||
|
if crash < 1.5 && after.BalanceMsat != wantLose {
|
||||||
|
t.Fatalf("round crashed at %.2fx, below the 1.50x target, but balance is %d not %d",
|
||||||
|
crash, after.BalanceMsat, wantLose)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Every balance change must be explained by the ledger.
|
||||||
|
var hist struct {
|
||||||
|
Entries []struct {
|
||||||
|
Kind string `json:"Kind"`
|
||||||
|
AmountMsat int64 `json:"AmountMsat"`
|
||||||
|
BalanceBefore int64 `json:"BalanceBefore"`
|
||||||
|
BalanceAfter int64 `json:"BalanceAfter"`
|
||||||
|
} `json:"entries"`
|
||||||
|
}
|
||||||
|
alice.do("GET", "/api/history", nil, &hist)
|
||||||
|
if len(hist.Entries) < 3 {
|
||||||
|
t.Fatalf("history has %d entries; expected at least deposit, scratch, bet",
|
||||||
|
len(hist.Entries))
|
||||||
|
}
|
||||||
|
// History is newest-first; walking backwards, each entry's before must be
|
||||||
|
// the previous entry's after.
|
||||||
|
for i := 0; i < len(hist.Entries)-1; i++ {
|
||||||
|
newer, older := hist.Entries[i], hist.Entries[i+1]
|
||||||
|
if newer.BalanceBefore != older.BalanceAfter {
|
||||||
|
t.Fatalf("ledger history is not continuous: %s starts at %d but the "+
|
||||||
|
"preceding %s ended at %d",
|
||||||
|
newer.Kind, newer.BalanceBefore, older.Kind, older.BalanceAfter)
|
||||||
|
}
|
||||||
|
if newer.BalanceAfter != newer.BalanceBefore+newer.AmountMsat {
|
||||||
|
t.Fatalf("%s entry does not add up: %d + %d != %d",
|
||||||
|
newer.Kind, newer.BalanceBefore, newer.AmountMsat, newer.BalanceAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Send sats to a friend; both sides must move by the same amount.
|
||||||
|
bobBefore := bob.fund(1)
|
||||||
|
aliceBefore := after.BalanceMsat
|
||||||
|
const gift = 500_000
|
||||||
|
var xfer struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
if code := alice.do("POST", "/api/transfer", map[string]any{
|
||||||
|
"to_pubkey": hex.EncodeToString(bob.pub), "amount_msat": gift,
|
||||||
|
}, &xfer); code != 200 {
|
||||||
|
t.Fatalf("transfer returned %d", code)
|
||||||
|
}
|
||||||
|
if xfer.BalanceMsat != aliceBefore-gift {
|
||||||
|
t.Fatalf("sender balance = %d, want %d", xfer.BalanceMsat, aliceBefore-gift)
|
||||||
|
}
|
||||||
|
var bobAfter struct {
|
||||||
|
BalanceMsat int64 `json:"balance_msat"`
|
||||||
|
}
|
||||||
|
bob.do("GET", "/api/balance", nil, &bobAfter)
|
||||||
|
if bobAfter.BalanceMsat != bobBefore+gift {
|
||||||
|
t.Fatalf("recipient balance = %d, want %d", bobAfter.BalanceMsat, bobBefore+gift)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. The books must still balance to zero after all of it.
|
||||||
|
var health struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
LedgerSumMsat int64 `json:"ledger_sum_msat"`
|
||||||
|
}
|
||||||
|
alice.do("GET", "/api/health", nil, &health)
|
||||||
|
if health.LedgerSumMsat != 0 {
|
||||||
|
t.Fatalf("after a full journey the books are off by %d msat", health.LedgerSumMsat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ func main() {
|
|||||||
go h.supervise(ctx)
|
go h.supervise(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sweep for rounds abandoned by an instance that died mid-flight and
|
||||||
|
// refund their stakes. Every instance runs this; the claim is atomic, so
|
||||||
|
// concurrent sweeps refund exactly once.
|
||||||
|
go room.NewReconciler(pool, s.ledger).RunPeriodically(ctx, 30*time.Second)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: s.routes(),
|
Handler: s.routes(),
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -78,11 +78,15 @@ t+6s both games taken over, rounds running
|
|||||||
Six seconds, unattended. Players attached to the dead instance reconnect
|
Six seconds, unattended. Players attached to the dead instance reconnect
|
||||||
through the load balancer and rejoin whichever instance answers.
|
through the load balancer and rejoin whichever instance answers.
|
||||||
|
|
||||||
The in-flight round on the dead instance is lost — bets already written to the
|
The in-flight round on the dead instance produces no outcome. Because stakes
|
||||||
ledger stand, and the round simply never settles. This is the one rough edge:
|
are debited when a bet is placed, those players would otherwise be quietly
|
||||||
stakes are debited at bet time, so a round lost mid-flight leaves those stakes
|
short — the books stay balanced, but the money sits with the house.
|
||||||
with the house. A reconciliation job that refunds unsettled rounds is not yet
|
|
||||||
built.
|
Every instance therefore runs a reconciler every 30 seconds. It finds rounds
|
||||||
|
left unresolved past a staleness window, marks them **void** (not settled: an
|
||||||
|
abandoned round has no outcome, so there is no seed to reveal), and refunds
|
||||||
|
every stake. Claiming the round happens before any money moves, so concurrent
|
||||||
|
reconcilers on different instances refund exactly once.
|
||||||
|
|
||||||
## Load balancing
|
## Load balancing
|
||||||
|
|
||||||
@@ -111,6 +115,31 @@ Returns every registered instance, which one drives each game, and which
|
|||||||
instance answered. Useful for confirming a clone joined, and for watching
|
instance answered. Useful for confirming a clone joined, and for watching
|
||||||
leadership move during a failover.
|
leadership move during a failover.
|
||||||
|
|
||||||
|
## Measured capacity
|
||||||
|
|
||||||
|
Run against one instance on a 4-core / 7GB box, with the load generator on the
|
||||||
|
*same machine* competing for CPU — so these are conservative:
|
||||||
|
|
||||||
|
| Connections | Failed | Dial p50 / p99 | Server RSS |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 500 | 0 | 1ms / 11ms | — |
|
||||||
|
| 3,000 | 0 | 1ms / 122ms | — |
|
||||||
|
| 10,000 | 0 | 1ms / 15ms | 258 MB |
|
||||||
|
| 25,000 | 0 | 2ms / 1.33s | 586 MB |
|
||||||
|
|
||||||
|
About **26KB of server memory per connection**, so 50,000 connections is
|
||||||
|
roughly 1.2GB — comfortable on any real machine. Connection capacity is not
|
||||||
|
the constraint people expect it to be.
|
||||||
|
|
||||||
|
Reproduce with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/loadtest -conns 10000 -duration 30s -ramp 20s
|
||||||
|
```
|
||||||
|
|
||||||
|
The dial p99 at 25k reflects both processes sharing four cores; on separate
|
||||||
|
machines it is far lower. Rising dial latency is the signal to add an instance.
|
||||||
|
|
||||||
## Where this stops scaling
|
## Where this stops scaling
|
||||||
|
|
||||||
Adding app clones raises the ceiling on connections and fan-out. It does not
|
Adding app clones raises the ceiling on connections and fan-out. It does not
|
||||||
@@ -120,6 +149,11 @@ raise these:
|
|||||||
cost. Every clone contends for the same database. Getting past this needs
|
cost. Every clone contends for the same database. Getting past this needs
|
||||||
in-memory balance reservation with batched persistence — a change to how
|
in-memory balance reservation with batched persistence — a change to how
|
||||||
money is held, not a deployment change.
|
money is held, not a deployment change.
|
||||||
|
|
||||||
|
This is the real ceiling, and it is worth being precise about what it means:
|
||||||
|
50,000 people can *watch* comfortably, and tens of thousands can hold
|
||||||
|
connections on a single instance. What they cannot all do is place a bet in
|
||||||
|
the same twenty-second window. A 20s window absorbs roughly 4,600 bets.
|
||||||
- **A single game's round loop** runs on one instance, by design. A game cannot
|
- **A single game's round loop** runs on one instance, by design. A game cannot
|
||||||
be split across instances without a distributed clock.
|
be split across instances without a distributed clock.
|
||||||
|
|
||||||
|
|||||||
21
migrations/0003_void_rounds.sql
Normal file
21
migrations/0003_void_rounds.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
-- A round that is abandoned is not the same as a round that settled.
|
||||||
|
--
|
||||||
|
-- Settlement means an outcome was produced, which is why reveal_is_complete
|
||||||
|
-- requires a settled round to publish its seed. A round whose instance died
|
||||||
|
-- mid-flight has no outcome at all: there is nothing to reveal, and marking it
|
||||||
|
-- settled would either violate that constraint or, worse, publish a seed for a
|
||||||
|
-- round that never resolved.
|
||||||
|
--
|
||||||
|
-- Voiding is its own state: stakes are returned and the round is closed with no
|
||||||
|
-- result.
|
||||||
|
|
||||||
|
ALTER TABLE rounds ADD COLUMN voided_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN rounds.voided_at IS
|
||||||
|
'Set when a round was abandoned and its stakes refunded. Mutually exclusive with settled_at.';
|
||||||
|
|
||||||
|
ALTER TABLE rounds ADD CONSTRAINT round_not_both_settled_and_void
|
||||||
|
CHECK (settled_at IS NULL OR voided_at IS NULL);
|
||||||
|
|
||||||
|
CREATE INDEX rounds_unresolved_idx ON rounds (opened_at)
|
||||||
|
WHERE settled_at IS NULL AND voided_at IS NULL;
|
||||||
192
pkg/room/reconcile.go
Normal file
192
pkg/room/reconcile.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package room
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/drjones/quantum-arcade/pkg/ledger"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reconciler refunds rounds that were abandoned mid-flight.
|
||||||
|
//
|
||||||
|
// Stakes are debited when a bet is placed, so if the instance driving a game
|
||||||
|
// dies between the bet and settlement, those stakes sit with the house and the
|
||||||
|
// round never resolves. The books stay balanced — nothing is created or lost —
|
||||||
|
// but the players are quietly short, which is the same thing as being robbed
|
||||||
|
// by accident.
|
||||||
|
//
|
||||||
|
// This finds those rounds and refunds every unsettled stake. It is safe to run
|
||||||
|
// repeatedly and from any instance: refunds are recorded against the round, and
|
||||||
|
// a round already marked settled is skipped.
|
||||||
|
type Reconciler struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
ledger *ledger.Ledger
|
||||||
|
|
||||||
|
// Stale is how long a round may remain unsettled before it is considered
|
||||||
|
// abandoned. It must exceed the longest possible round plus the time it
|
||||||
|
// takes another instance to take over, or a live round would be refunded
|
||||||
|
// out from under the players still in it.
|
||||||
|
Stale time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReconciler(pool *pgxpool.Pool, l *ledger.Ledger) *Reconciler {
|
||||||
|
return &Reconciler{
|
||||||
|
pool: pool,
|
||||||
|
ledger: l,
|
||||||
|
// A round is capped at 60s of flight plus its betting and settle
|
||||||
|
// phases; leadership moves within LeaseTTL. Two minutes is far past
|
||||||
|
// any legitimate round and still prompt enough to matter at a party.
|
||||||
|
Stale: 2 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result describes what a reconciliation pass did.
|
||||||
|
type Result struct {
|
||||||
|
RoundsRefunded int
|
||||||
|
BetsRefunded int
|
||||||
|
MsatRefunded int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run refunds every abandoned round it finds.
|
||||||
|
func (rc *Reconciler) Run(ctx context.Context) (Result, error) {
|
||||||
|
var res Result
|
||||||
|
|
||||||
|
rows, err := rc.pool.Query(ctx, `
|
||||||
|
SELECT DISTINCT r.id
|
||||||
|
FROM rounds r
|
||||||
|
JOIN bets b ON b.round_id = r.id
|
||||||
|
WHERE r.settled_at IS NULL
|
||||||
|
AND r.voided_at IS NULL
|
||||||
|
AND b.settled_at IS NULL
|
||||||
|
AND r.opened_at < now() - make_interval(secs => $1)
|
||||||
|
ORDER BY r.id`,
|
||||||
|
rc.Stale.Seconds())
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("finding abandoned rounds: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var roundIDs []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
roundIDs = append(roundIDs, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, roundID := range roundIDs {
|
||||||
|
refunded, msat, err := rc.refundRound(ctx, roundID)
|
||||||
|
if err != nil {
|
||||||
|
// One bad round must not stop the rest from being made whole.
|
||||||
|
fmt.Printf("reconcile: round %d: %v\n", roundID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if refunded > 0 {
|
||||||
|
res.RoundsRefunded++
|
||||||
|
res.BetsRefunded += refunded
|
||||||
|
res.MsatRefunded += msat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refundRound returns every unsettled stake in one round.
|
||||||
|
func (rc *Reconciler) refundRound(ctx context.Context, roundID int64) (int, int64, error) {
|
||||||
|
// Claim the round by voiding it. Doing this before moving money means a
|
||||||
|
// second pass — or another instance running concurrently — finds nothing
|
||||||
|
// to do, so a refund cannot be issued twice.
|
||||||
|
//
|
||||||
|
// Void, not settled: an abandoned round produced no outcome, so it has no
|
||||||
|
// seed to reveal and must not masquerade as a resolved round.
|
||||||
|
tag, err := rc.pool.Exec(ctx,
|
||||||
|
`UPDATE rounds SET voided_at = now()
|
||||||
|
WHERE id = $1 AND settled_at IS NULL AND voided_at IS NULL`, roundID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("claiming round: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return 0, 0, nil // another pass got there first
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := rc.pool.Query(ctx,
|
||||||
|
`SELECT account_id, stake_msat FROM bets
|
||||||
|
WHERE round_id = $1 AND settled_at IS NULL`, roundID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
type refund struct {
|
||||||
|
account int64
|
||||||
|
msat int64
|
||||||
|
}
|
||||||
|
var refunds []refund
|
||||||
|
for rows.Next() {
|
||||||
|
var r refund
|
||||||
|
if err := rows.Scan(&r.account, &r.msat); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
refunds = append(refunds, r)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if len(refunds) == 0 {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
house, err := rc.ledger.AccountByName(ctx, "house_pot")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
postings := make([]ledger.Posting, 0, len(refunds)+1)
|
||||||
|
var total int64
|
||||||
|
for _, r := range refunds {
|
||||||
|
postings = append(postings, ledger.Posting{AccountID: r.account, AmountMsat: r.msat})
|
||||||
|
total += r.msat
|
||||||
|
}
|
||||||
|
postings = append(postings, ledger.Posting{AccountID: house, AmountMsat: -total})
|
||||||
|
|
||||||
|
rid := roundID
|
||||||
|
if _, err := rc.ledger.Post(ctx, "refund_abandoned", &rid, postings); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("posting refunds: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := rc.pool.Exec(ctx,
|
||||||
|
`UPDATE bets SET settled_at = now(), payout_msat = stake_msat
|
||||||
|
WHERE round_id = $1 AND settled_at IS NULL`, roundID); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("marking bets refunded: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(refunds), total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunPeriodically sweeps for abandoned rounds until the context ends.
|
||||||
|
func (rc *Reconciler) RunPeriodically(ctx context.Context, every time.Duration) {
|
||||||
|
t := time.NewTicker(every)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
res, err := rc.Run(ctx)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("reconcile: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if res.RoundsRefunded > 0 {
|
||||||
|
fmt.Printf("reconcile: refunded %d bets across %d abandoned rounds (%d msat)\n",
|
||||||
|
res.BetsRefunded, res.RoundsRefunded, res.MsatRefunded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -814,3 +814,185 @@ func TestManualCashOutOverridesAPendingTarget(t *testing.T) {
|
|||||||
t.Fatalf("manual cash-out returned %v, expected the current multiplier", at)
|
t.Fatalf("manual cash-out returned %v, expected the current multiplier", at)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- abandoned round reconciliation ---------------- */
|
||||||
|
|
||||||
|
// The scenario: an instance takes bets, then dies before settling. The stakes
|
||||||
|
// have already left the players' balances. Nobody should be quietly short.
|
||||||
|
func TestAbandonedRoundIsRefunded(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
rc := NewReconciler(f.room.pool, f.ledger)
|
||||||
|
rc.Stale = 0 // treat everything as abandoned, for the test
|
||||||
|
|
||||||
|
id, pk := f.player("a", 100_000)
|
||||||
|
before, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
f.openBetting()
|
||||||
|
const stake = 10_000
|
||||||
|
if err := f.room.PlaceBet(f.ctx, id, pk, "a", stake, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
afterBet, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if before-afterBet != stake {
|
||||||
|
t.Fatalf("stake not taken: %d", before-afterBet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The instance dies here: the round is never settled.
|
||||||
|
res, err := rc.Run(f.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.BetsRefunded < 1 {
|
||||||
|
t.Fatalf("nothing refunded: %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterRefund, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if afterRefund != before {
|
||||||
|
t.Fatalf("balance = %d after refund, want %d (the original stake back)",
|
||||||
|
afterRefund, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running twice must not pay twice.
|
||||||
|
func TestReconcileIsIdempotent(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
rc := NewReconciler(f.room.pool, f.ledger)
|
||||||
|
rc.Stale = 0
|
||||||
|
|
||||||
|
id, pk := f.player("a", 100_000)
|
||||||
|
before, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
f.openBetting()
|
||||||
|
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := rc.Run(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
afterFirst, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := rc.Run(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
afterRepeats, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
if afterRepeats != afterFirst {
|
||||||
|
t.Fatalf("repeated reconciliation paid again: %d -> %d", afterFirst, afterRepeats)
|
||||||
|
}
|
||||||
|
if afterFirst != before {
|
||||||
|
t.Fatalf("refund was not exactly the stake: %d, want %d", afterFirst, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent reconcilers, as two instances would be, must still refund once.
|
||||||
|
func TestConcurrentReconcilersRefundOnce(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
rc1 := NewReconciler(f.room.pool, f.ledger)
|
||||||
|
rc2 := NewReconciler(f.room.pool, f.ledger)
|
||||||
|
rc1.Stale, rc2.Stale = 0, 0
|
||||||
|
|
||||||
|
id, pk := f.player("a", 100_000)
|
||||||
|
before, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
f.openBetting()
|
||||||
|
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, rc := range []*Reconciler{rc1, rc2, rc1, rc2} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(rc *Reconciler) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _ = rc.Run(f.ctx)
|
||||||
|
}(rc)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if after != before {
|
||||||
|
t.Fatalf("concurrent reconcilers refunded %d, want exactly the stake (%d)",
|
||||||
|
after-before+10_000, 10_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A round that settled normally must never be refunded on top of its payout.
|
||||||
|
func TestSettledRoundIsNotRefunded(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
rc := NewReconciler(f.room.pool, f.ledger)
|
||||||
|
rc.Stale = 0
|
||||||
|
|
||||||
|
id, pk := f.player("a", 100_000)
|
||||||
|
f.openBetting()
|
||||||
|
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
f.startRun()
|
||||||
|
if err := f.room.settle(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
afterSettle, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
if _, err := rc.Run(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
afterReconcile, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
if afterReconcile != afterSettle {
|
||||||
|
t.Fatalf("a settled round was refunded: %d -> %d", afterSettle, afterReconcile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A round still in flight must be left alone.
|
||||||
|
func TestLiveRoundIsNotRefunded(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
rc := NewReconciler(f.room.pool, f.ledger)
|
||||||
|
rc.Stale = 2 * time.Minute // the production value
|
||||||
|
|
||||||
|
id, pk := f.player("a", 100_000)
|
||||||
|
f.openBetting()
|
||||||
|
if err := f.room.PlaceBet(f.ctx, id, pk, "a", 10_000, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
afterBet, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
|
||||||
|
res, err := rc.Run(f.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.RoundsRefunded != 0 {
|
||||||
|
t.Fatalf("a live round was refunded out from under its players: %+v", res)
|
||||||
|
}
|
||||||
|
after, _ := f.ledger.Balance(f.ctx, id)
|
||||||
|
if after != afterBet {
|
||||||
|
t.Fatalf("balance changed on a live round: %d -> %d", afterBet, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The books must still balance after a refund.
|
||||||
|
func TestBooksBalanceAfterRefund(t *testing.T) {
|
||||||
|
f := newFixture(t)
|
||||||
|
rc := NewReconciler(f.room.pool, f.ledger)
|
||||||
|
rc.Stale = 0
|
||||||
|
|
||||||
|
f.openBetting()
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
id, pk := f.player(fmt.Sprintf("p%d", i), 100_000)
|
||||||
|
if err := f.room.PlaceBet(f.ctx, id, pk, "p", 10_000, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := rc.Run(f.ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
total, err := f.ledger.ConservationCheck(f.ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 0 {
|
||||||
|
t.Fatalf("books do not balance after refunds: %d", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user