Files
casino/cmd/arcade/hub.go
drjones c12640cf52 feat(cluster): zero-config horizontal scaling by cloning
An instance decides what it is at startup instead of being told: it
generates its own identity, registers a heartbeat, and campaigns for
each game. Exactly one instance drives a game's rounds and publishes
frames; the rest relay them and forward mutations to the leader. Clone
the VM, boot it, done.

Sessions and the scratch nonce move to Redis. Both were per-instance
state that would have broken behind a load balancer: a token minted by
one clone was unknown to the others, and two clones would have handed
the same nonce to different players, which for the same key means the
same outcome.

Fixes a bug found by running two instances: /api/games read the local
room object, so a follower reported a permanently settled game and its
clients never saw a betting window. Hubs now serve the last frame they
saw, produced or relayed.

Failover measured at 6s after kill -9 on an instance leading two games.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:07:47 +00:00

272 lines
6.2 KiB
Go

package main
import (
"context"
"errors"
"log"
"net"
"os"
"strings"
"sync"
"time"
"github.com/drjones/quantum-arcade/pkg/cluster"
"github.com/drjones/quantum-arcade/pkg/room"
)
// gameHub owns one game across the cluster.
//
// Exactly one instance leads a game: it runs the round loop, drives the
// simulation, settles to the ledger, and publishes each frame. Every other
// instance relays those frames to its own connected clients. Clients cannot
// tell the difference, and neither can the ledger.
//
// Roles are renegotiated on a timer rather than agreed once, so an instance
// disappearing is not a special case — its lease simply stops being renewed
// and the next campaign hands the game to someone else.
type gameHub struct {
game string
room *room.Room
node *cluster.Node
mu sync.RWMutex
leading bool
subs map[chan []byte]struct{}
// lastFrame is the most recent frame this instance saw, whether it
// produced it or relayed it. A follower's own room object sits idle, so
// this — not the local room — is what any read of "current state" must
// use, or a follower would report a permanently settled game.
lastFrame []byte
// cancelLead stops the leader's round loop when leadership is lost.
cancelLead context.CancelFunc
}
func newGameHub(game string, r *room.Room, node *cluster.Node) *gameHub {
return &gameHub{
game: game,
room: r,
node: node,
subs: make(map[chan []byte]struct{}),
}
}
// Subscribe returns frames for this game, whether this instance is producing
// them or relaying them.
func (h *gameHub) Subscribe() (<-chan []byte, func()) {
ch := make(chan []byte, 4)
h.mu.Lock()
h.subs[ch] = struct{}{}
h.mu.Unlock()
return ch, func() {
h.mu.Lock()
delete(h.subs, ch)
close(ch)
h.mu.Unlock()
}
}
// fanout delivers a frame to this instance's own clients. A client that has
// stopped reading is skipped rather than allowed to stall the game.
func (h *gameHub) fanout(payload []byte) {
h.mu.Lock()
h.lastFrame = payload
h.mu.Unlock()
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.subs {
select {
case ch <- payload:
default:
}
}
}
// LastFrame returns the most recent state this instance knows about, and
// whether it has seen one yet.
func (h *gameHub) LastFrame() ([]byte, bool) {
h.mu.RLock()
defer h.mu.RUnlock()
return h.lastFrame, len(h.lastFrame) > 0
}
// Leading reports whether this instance currently drives the game.
func (h *gameHub) Leading() bool {
h.mu.RLock()
defer h.mu.RUnlock()
return h.leading
}
// supervise campaigns for the game and switches roles as leadership moves.
func (h *gameHub) supervise(ctx context.Context) {
// Followers hold a subscription to the cluster's frame channel. It is torn
// down on promotion so a leader never relays its own frames back to itself.
var stopRelay func()
defer func() {
if stopRelay != nil {
stopRelay()
}
}()
ticker := time.NewTicker(cluster.RenewInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
h.demote()
return
case <-ticker.C:
}
var held bool
var err error
if h.Leading() {
held, err = h.node.Renew(ctx, h.game)
} else {
held, err = h.node.Campaign(ctx, h.game)
}
if err != nil {
// Redis is unreachable. A current leader keeps running rather than
// abandoning a round mid-flight; its lease will expire and another
// instance will take over if the outage outlasts it.
log.Printf("hub %s: coordination error: %v", h.game, err)
continue
}
switch {
case held && !h.Leading():
if stopRelay != nil {
stopRelay()
stopRelay = nil
}
h.promote(ctx)
case !held && h.Leading():
h.demote()
stopRelay = h.startRelay(ctx)
case !held && stopRelay == nil:
// Follower with no relay yet — subscribe so clients see the game.
stopRelay = h.startRelay(ctx)
}
}
}
// promote starts driving the game on this instance.
func (h *gameHub) promote(ctx context.Context) {
leadCtx, cancel := context.WithCancel(ctx)
h.mu.Lock()
h.leading = true
h.cancelLead = cancel
h.mu.Unlock()
log.Printf("hub %s: leading", h.game)
// Forward the room's frames both to this instance's clients and to peers.
frames, unsubscribe := h.room.Subscribe()
go func() {
defer unsubscribe()
for {
select {
case <-leadCtx.Done():
return
case payload, ok := <-frames:
if !ok {
return
}
h.fanout(payload)
if err := h.node.PublishFrame(leadCtx, h.game, payload); err != nil &&
!errors.Is(err, context.Canceled) {
log.Printf("hub %s: publishing frame: %v", h.game, err)
}
}
}
}()
go func() {
if err := h.room.Run(leadCtx); err != nil && !errors.Is(err, context.Canceled) {
log.Printf("hub %s: round loop stopped: %v", h.game, err)
}
}()
}
// demote stops driving the game.
func (h *gameHub) demote() {
h.mu.Lock()
wasLeading := h.leading
h.leading = false
cancel := h.cancelLead
h.cancelLead = nil
h.mu.Unlock()
if cancel != nil {
cancel()
}
if wasLeading {
log.Printf("hub %s: no longer leading", h.game)
}
}
// startRelay subscribes to the leader's frames and passes them to this
// instance's clients.
func (h *gameHub) startRelay(ctx context.Context) func() {
frames, unsubscribe := h.node.SubscribeFrames(ctx, h.game)
relayCtx, cancel := context.WithCancel(ctx)
go func() {
for {
select {
case <-relayCtx.Done():
return
case payload, ok := <-frames:
if !ok {
return
}
h.fanout(payload)
}
}
}()
return func() {
cancel()
unsubscribe()
}
}
// advertiseAddr is how peers reach this instance.
//
// It prefers an explicit setting, then the first non-loopback address it can
// find — so a cloned VM that gets its address from DHCP advertises correctly
// without being told what it is.
func advertiseAddr() string {
if v := os.Getenv("ARCADE_ADVERTISE"); v != "" {
return v
}
port := os.Getenv("ARCADE_ADDR")
if port == "" {
port = ":8080"
}
if !strings.HasPrefix(port, ":") {
if _, p, err := net.SplitHostPort(port); err == nil {
port = ":" + p
}
}
addrs, err := net.InterfaceAddrs()
if err != nil {
return "127.0.0.1" + port
}
for _, a := range addrs {
ipnet, ok := a.(*net.IPNet)
if !ok || ipnet.IP.IsLoopback() || ipnet.IP.To4() == nil {
continue
}
return ipnet.IP.String() + port
}
return "127.0.0.1" + port
}