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>
This commit is contained in:
69
pkg/room/bench_test.go
Normal file
69
pkg/room/bench_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package room
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Broadcast cost decides whether a crowd can watch the same round. At 60Hz
|
||||
// with N subscribers the server does N marshals per tick unless the payload is
|
||||
// serialised once and shared.
|
||||
func BenchmarkSnapshotMarshal(b *testing.B) {
|
||||
r := New("rocket", nil, nil)
|
||||
for i := 0; i < 200; i++ {
|
||||
r.bets[int64(i)] = &Bet{
|
||||
AccountID: int64(i),
|
||||
Pubkey: []byte("0123456789abcdef0123456789abcdef"),
|
||||
Nickname: "player",
|
||||
StakeMsat: 10000,
|
||||
}
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
snap := r.Snapshot()
|
||||
if _, err := json.Marshal(snap); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot alone, without serialisation: this is the lock-held portion, which
|
||||
// blocks every other operation on the room.
|
||||
func BenchmarkSnapshotOnly(b *testing.B) {
|
||||
r := New("rocket", nil, nil)
|
||||
for i := 0; i < 200; i++ {
|
||||
r.bets[int64(i)] = &Bet{
|
||||
AccountID: int64(i),
|
||||
Pubkey: []byte("0123456789abcdef0123456789abcdef"),
|
||||
Nickname: "player",
|
||||
StakeMsat: 10000,
|
||||
}
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = r.Snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
// Fan-out to many subscriber channels.
|
||||
func BenchmarkBroadcast1000Subscribers(b *testing.B) {
|
||||
r := New("rocket", nil, nil)
|
||||
for i := 0; i < 50; i++ {
|
||||
r.bets[int64(i)] = &Bet{
|
||||
AccountID: int64(i), Pubkey: []byte("key"),
|
||||
Nickname: "p", StakeMsat: 1000,
|
||||
}
|
||||
}
|
||||
// Drain subscribers so the buffered channels do not simply fill.
|
||||
for i := 0; i < 1000; i++ {
|
||||
ch, _ := r.Subscribe()
|
||||
go func(c <-chan []byte) {
|
||||
for range c {
|
||||
}
|
||||
}(ch)
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
r.broadcast()
|
||||
}
|
||||
}
|
||||
112
pkg/room/room.go
112
pkg/room/room.go
@@ -12,7 +12,9 @@ package room
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -61,23 +63,47 @@ type Bet struct {
|
||||
// Snapshot is what clients render. It carries the seed inputs so a client can
|
||||
// verify the round the moment it settles.
|
||||
type Snapshot struct {
|
||||
RoundID int64 `json:"round_id"`
|
||||
Game string `json:"game"`
|
||||
State State `json:"state"`
|
||||
Tick int `json:"tick"`
|
||||
Multiplier string `json:"multiplier"`
|
||||
Commitment string `json:"commitment"`
|
||||
ServerSeed string `json:"server_seed,omitempty"` // only once settled
|
||||
CrashPoint string `json:"crash_point,omitempty"` // only once settled
|
||||
RoundID int64 `json:"round_id"`
|
||||
Game string `json:"game"`
|
||||
State State `json:"state"`
|
||||
Tick int `json:"tick"`
|
||||
Multiplier string `json:"multiplier"`
|
||||
Commitment string `json:"commitment"`
|
||||
ServerSeed string `json:"server_seed,omitempty"` // only once settled
|
||||
CrashPoint string `json:"crash_point,omitempty"` // only once settled
|
||||
// Players is capped at MaxListedPlayers. Sending every player to every
|
||||
// subscriber is O(n^2) in bandwidth and makes a large room impossible:
|
||||
// 50k players broadcast to 50k phones is gigabytes per second. The full
|
||||
// list is available on request; the feed carries the leaderboard.
|
||||
Players []Player `json:"players"`
|
||||
PlayerCount int `json:"player_count"`
|
||||
PotMsat int64 `json:"pot_msat"`
|
||||
CashedOut int `json:"cashed_out_count"`
|
||||
HousePotMsat int64 `json:"house_pot_msat"`
|
||||
NextPhaseIn float64 `json:"next_phase_in_seconds"`
|
||||
|
||||
// StartedUnixMilli is when the running phase began. Because the multiplier
|
||||
// curve is deterministic, a client can compute the current value locally
|
||||
// from this instead of being told it sixty times a second.
|
||||
StartedUnixMilli int64 `json:"started_unix_milli,omitempty"`
|
||||
}
|
||||
|
||||
// MaxListedPlayers bounds the per-frame player list.
|
||||
const MaxListedPlayers = 24
|
||||
|
||||
// BroadcastHz is how often a running round pushes a frame. Clients compute the
|
||||
// multiplier locally between frames, so this only has to be often enough to
|
||||
// correct drift and deliver cash-out news.
|
||||
const BroadcastHz = 5
|
||||
|
||||
// Player is the public view of a participant.
|
||||
type Player struct {
|
||||
Nickname string `json:"nickname"`
|
||||
PubkeyHex string `json:"pubkey"`
|
||||
Nickname string `json:"nickname"`
|
||||
// Pubkey is omitted from the live feed: it is 64 hex characters, it is
|
||||
// most of the frame, and nothing in the interface displays it. The full
|
||||
// participant list, with keys, is served by the verification endpoint
|
||||
// after settlement — which is where it actually matters.
|
||||
PubkeyHex string `json:"pubkey,omitempty"`
|
||||
StakeMsat int64 `json:"stake_msat"`
|
||||
CashedOut string `json:"cashed_out,omitempty"`
|
||||
PayoutMsat int64 `json:"payout_msat"`
|
||||
@@ -104,8 +130,11 @@ type Room struct {
|
||||
order [][]byte // participant pubkeys in join order
|
||||
phaseEnds time.Time
|
||||
|
||||
subscribers map[chan Snapshot]struct{}
|
||||
subscribers map[chan []byte]struct{}
|
||||
subMu sync.Mutex
|
||||
|
||||
// runStarted is when the current running phase began.
|
||||
runStarted time.Time
|
||||
}
|
||||
|
||||
func New(game string, pool *pgxpool.Pool, l *ledger.Ledger) *Room {
|
||||
@@ -115,7 +144,7 @@ func New(game string, pool *pgxpool.Pool, l *ledger.Ledger) *Room {
|
||||
ledger: l,
|
||||
state: StateSettled,
|
||||
bets: make(map[int64]*Bet),
|
||||
subscribers: make(map[chan Snapshot]struct{}),
|
||||
subscribers: make(map[chan []byte]struct{}),
|
||||
phaseEnds: time.Now(),
|
||||
}
|
||||
}
|
||||
@@ -123,8 +152,8 @@ func New(game string, pool *pgxpool.Pool, l *ledger.Ledger) *Room {
|
||||
// Subscribe returns a channel of snapshots. The channel is buffered and drops
|
||||
// updates rather than blocking the round loop: a slow phone must never stall
|
||||
// the game for everyone else.
|
||||
func (r *Room) Subscribe() (<-chan Snapshot, func()) {
|
||||
ch := make(chan Snapshot, 8)
|
||||
func (r *Room) Subscribe() (<-chan []byte, func()) {
|
||||
ch := make(chan []byte, 4)
|
||||
r.subMu.Lock()
|
||||
r.subscribers[ch] = struct{}{}
|
||||
r.subMu.Unlock()
|
||||
@@ -137,14 +166,24 @@ func (r *Room) Subscribe() (<-chan Snapshot, func()) {
|
||||
}
|
||||
}
|
||||
|
||||
// broadcast serialises the snapshot once and hands the same bytes to every
|
||||
// subscriber.
|
||||
//
|
||||
// Letting each connection marshal its own copy costs ~355us per subscriber per
|
||||
// frame, which at any real crowd size exceeds the tick interval by orders of
|
||||
// magnitude. One marshal per frame turns fan-out into a pointer copy.
|
||||
func (r *Room) broadcast() {
|
||||
snap := r.Snapshot()
|
||||
payload, err := json.Marshal(r.Snapshot())
|
||||
if err != nil {
|
||||
fmt.Printf("room %s: marshalling snapshot: %v\n", r.Game, err)
|
||||
return
|
||||
}
|
||||
r.subMu.Lock()
|
||||
defer r.subMu.Unlock()
|
||||
for ch := range r.subscribers {
|
||||
select {
|
||||
case ch <- snap:
|
||||
default: // subscriber is behind; skip this frame
|
||||
case ch <- payload:
|
||||
default: // subscriber is behind; drop this frame rather than stall
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,7 +247,17 @@ func (r *Room) step(ctx context.Context) error {
|
||||
if crashed {
|
||||
return r.settle(ctx)
|
||||
}
|
||||
r.broadcast()
|
||||
// The multiplier is a pure function of the tick, and the client has
|
||||
// the same curve. So the feed does not need to carry it sixty times a
|
||||
// second: clients interpolate locally from StartedUnixMilli and the
|
||||
// server sends a correcting frame a few times a second.
|
||||
//
|
||||
// At 60Hz this fan-out was the single largest cost in the system. At
|
||||
// BroadcastHz it is a rounding error, and the animation is smoother
|
||||
// because it is no longer gated on network jitter.
|
||||
if r.tick%(sim.TickHz/BroadcastHz) == 0 {
|
||||
r.broadcast()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -256,6 +305,7 @@ func (r *Room) startRunning(ctx context.Context) error {
|
||||
r.crashPoint = sim.CrashPoint(roundSeed)
|
||||
r.state = StateRunning
|
||||
r.tick = 0
|
||||
r.runStarted = time.Now()
|
||||
roundID := r.roundID
|
||||
crash := r.crashPoint
|
||||
r.mu.Unlock()
|
||||
@@ -449,11 +499,27 @@ func (r *Room) Snapshot() Snapshot {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
players := make([]Player, 0, len(r.bets))
|
||||
// Aggregate over every player, but only serialise the largest few.
|
||||
var pot int64
|
||||
cashed := 0
|
||||
all := make([]*Bet, 0, len(r.bets))
|
||||
for _, b := range r.bets {
|
||||
pot += b.StakeMsat
|
||||
if b.CashedOutAt != 0 {
|
||||
cashed++
|
||||
}
|
||||
all = append(all, b)
|
||||
}
|
||||
// Partial ordering is enough: the list is a leaderboard, not a ledger.
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].StakeMsat > all[j].StakeMsat })
|
||||
if len(all) > MaxListedPlayers {
|
||||
all = all[:MaxListedPlayers]
|
||||
}
|
||||
|
||||
players := make([]Player, 0, len(all))
|
||||
for _, b := range all {
|
||||
p := Player{
|
||||
Nickname: b.Nickname,
|
||||
PubkeyHex: hex.EncodeToString(b.Pubkey),
|
||||
StakeMsat: b.StakeMsat,
|
||||
PayoutMsat: b.PayoutMsat,
|
||||
}
|
||||
@@ -472,8 +538,14 @@ func (r *Room) Snapshot() Snapshot {
|
||||
Multiplier: sim.MultiplierAt(r.tick).String(),
|
||||
Commitment: hex.EncodeToString(r.commitment[:]),
|
||||
Players: players,
|
||||
PlayerCount: len(r.bets),
|
||||
PotMsat: pot,
|
||||
CashedOut: cashed,
|
||||
NextPhaseIn: time.Until(r.phaseEnds).Seconds(),
|
||||
}
|
||||
if r.state == StateRunning {
|
||||
s.StartedUnixMilli = r.runStarted.UnixMilli()
|
||||
}
|
||||
// The seed is revealed only once the round is over.
|
||||
if r.state == StateSettled && r.crashPoint != 0 {
|
||||
s.ServerSeed = r.serverSeed.Hex()
|
||||
|
||||
@@ -2,6 +2,7 @@ package room
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
@@ -489,7 +490,11 @@ func TestSubscriberReceivesUpdates(t *testing.T) {
|
||||
f.openBetting()
|
||||
|
||||
select {
|
||||
case snap := <-ch:
|
||||
case payload := <-ch:
|
||||
var snap Snapshot
|
||||
if err := json.Unmarshal(payload, &snap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap.State != StateBetting {
|
||||
t.Fatalf("received state %q, want betting_open", snap.State)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user