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>
327 lines
9.2 KiB
Go
327 lines
9.2 KiB
Go
// Package cluster lets identical instances cooperate without configuration.
|
|
//
|
|
// The intended operation is: clone the VM, boot it, done. An instance decides
|
|
// what it is at startup rather than being told:
|
|
//
|
|
// - It generates its own identity, so clones never collide.
|
|
// - It registers a heartbeat in Redis, so every instance sees the others.
|
|
// - It campaigns for leadership of each game room. Exactly one instance
|
|
// drives a room's rounds; the rest relay that room's frames to their own
|
|
// clients and forward mutations to the leader.
|
|
//
|
|
// Leadership is a Redis key held with a TTL and renewed. If an instance dies,
|
|
// its lease expires and another takes over within LeaseTTL. Nothing needs to
|
|
// notice the failure or intervene.
|
|
//
|
|
// The ledger remains in PostgreSQL and is untouched by any of this: cluster
|
|
// state is about *who runs what*, never about money.
|
|
package cluster
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
const (
|
|
// LeaseTTL is how long a leadership claim survives without renewal. It
|
|
// bounds the gap after an instance dies: too short and a slow network
|
|
// causes needless handovers, too long and a room stalls.
|
|
LeaseTTL = 6 * time.Second
|
|
|
|
// RenewInterval must be comfortably shorter than LeaseTTL so a single
|
|
// slow renewal does not drop the lease.
|
|
RenewInterval = 2 * time.Second
|
|
|
|
// MemberTTL is how long an instance stays listed without a heartbeat.
|
|
MemberTTL = 15 * time.Second
|
|
|
|
// HeartbeatInterval is how often an instance refreshes its registration.
|
|
HeartbeatInterval = 5 * time.Second
|
|
|
|
memberPrefix = "qa:member:"
|
|
leaderPrefix = "qa:leader:"
|
|
framePrefix = "qa:frames:"
|
|
)
|
|
|
|
// Member describes one instance of the arcade.
|
|
type Member struct {
|
|
ID string `json:"id"`
|
|
Hostname string `json:"hostname"`
|
|
Address string `json:"address"` // where peers reach it, host:port
|
|
Since int64 `json:"since_unix"`
|
|
}
|
|
|
|
// Node is this instance's view of the cluster.
|
|
type Node struct {
|
|
ID string
|
|
Hostname string
|
|
Address string
|
|
|
|
rdb *redis.Client
|
|
|
|
mu sync.RWMutex
|
|
led map[string]bool // rooms this instance currently leads
|
|
|
|
stop chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
// NewNode creates this instance's identity.
|
|
//
|
|
// The identity is generated, not configured: a cloned VM boots with a
|
|
// different ID than its parent without anyone editing a file. Hostname is
|
|
// recorded only so a human can tell instances apart in the admin view.
|
|
func NewNode(rdb *redis.Client, advertiseAddr string) *Node {
|
|
var raw [8]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
panic("cluster: system randomness unavailable: " + err.Error())
|
|
}
|
|
host, err := os.Hostname()
|
|
if err != nil || host == "" {
|
|
host = "unknown"
|
|
}
|
|
return &Node{
|
|
ID: hex.EncodeToString(raw[:]),
|
|
Hostname: host,
|
|
Address: advertiseAddr,
|
|
rdb: rdb,
|
|
led: make(map[string]bool),
|
|
stop: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Start begins heartbeating. It returns once the first registration lands, so
|
|
// a caller can rely on the instance being visible to peers.
|
|
func (n *Node) Start(ctx context.Context) error {
|
|
if err := n.heartbeat(ctx); err != nil {
|
|
return fmt.Errorf("cluster: registering: %w", err)
|
|
}
|
|
go func() {
|
|
t := time.NewTicker(HeartbeatInterval)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-n.stop:
|
|
return
|
|
case <-t.C:
|
|
if err := n.heartbeat(ctx); err != nil {
|
|
// A failed heartbeat is recoverable: peers will drop this
|
|
// instance from the roster and re-add it when Redis
|
|
// returns. Local play continues meanwhile.
|
|
fmt.Printf("cluster: heartbeat failed: %v\n", err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
// Stop ends heartbeating and releases every leadership this instance holds, so
|
|
// a planned shutdown hands rooms over immediately instead of after a timeout.
|
|
func (n *Node) Stop(ctx context.Context) {
|
|
n.once.Do(func() { close(n.stop) })
|
|
|
|
n.mu.Lock()
|
|
rooms := make([]string, 0, len(n.led))
|
|
for room := range n.led {
|
|
rooms = append(rooms, room)
|
|
}
|
|
n.mu.Unlock()
|
|
|
|
for _, room := range rooms {
|
|
_ = n.Resign(ctx, room)
|
|
}
|
|
_ = n.rdb.Del(ctx, memberPrefix+n.ID).Err()
|
|
}
|
|
|
|
func (n *Node) heartbeat(ctx context.Context) error {
|
|
m := Member{
|
|
ID: n.ID, Hostname: n.Hostname, Address: n.Address,
|
|
Since: time.Now().Unix(),
|
|
}
|
|
payload := fmt.Sprintf("%s|%s|%s|%d", m.ID, m.Hostname, m.Address, m.Since)
|
|
return n.rdb.Set(ctx, memberPrefix+n.ID, payload, MemberTTL).Err()
|
|
}
|
|
|
|
// Members lists every instance currently heartbeating, including this one.
|
|
func (n *Node) Members(ctx context.Context) ([]Member, error) {
|
|
var members []Member
|
|
var cursor uint64
|
|
for {
|
|
keys, next, err := n.rdb.Scan(ctx, cursor, memberPrefix+"*", 100).Result()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, k := range keys {
|
|
val, err := n.rdb.Get(ctx, k).Result()
|
|
if errors.Is(err, redis.Nil) {
|
|
continue // expired between the scan and the read
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parts := strings.SplitN(val, "|", 4)
|
|
if len(parts) != 4 {
|
|
continue
|
|
}
|
|
var since int64
|
|
fmt.Sscanf(parts[3], "%d", &since)
|
|
members = append(members, Member{
|
|
ID: parts[0], Hostname: parts[1], Address: parts[2], Since: since,
|
|
})
|
|
}
|
|
cursor = next
|
|
if cursor == 0 {
|
|
break
|
|
}
|
|
}
|
|
return members, nil
|
|
}
|
|
|
|
// Campaign attempts to take leadership of a room.
|
|
//
|
|
// It reports whether this instance now leads. Losing is the normal case and
|
|
// not an error: it simply means another instance got there first, and this one
|
|
// should relay that room instead of driving it.
|
|
func (n *Node) Campaign(ctx context.Context, room string) (bool, error) {
|
|
won, err := n.rdb.SetNX(ctx, leaderPrefix+room, n.ID, LeaseTTL).Result()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if won {
|
|
n.mu.Lock()
|
|
n.led[room] = true
|
|
n.mu.Unlock()
|
|
return true, nil
|
|
}
|
|
// Already ours? Then a renewal was simply slower than a campaign.
|
|
holder, err := n.rdb.Get(ctx, leaderPrefix+room).Result()
|
|
if err != nil && !errors.Is(err, redis.Nil) {
|
|
return false, err
|
|
}
|
|
return holder == n.ID, nil
|
|
}
|
|
|
|
// renewScript extends the lease only if this instance still holds it. Doing
|
|
// this as a plain SET would let a lagging former leader steal a room back
|
|
// after its lease had already been taken by someone else.
|
|
var renewScript = redis.NewScript(`
|
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
|
|
end
|
|
return 0
|
|
`)
|
|
|
|
// Renew extends leadership of a room. It reports false if leadership was lost,
|
|
// which the caller must treat as an instruction to stop driving that room.
|
|
func (n *Node) Renew(ctx context.Context, room string) (bool, error) {
|
|
res, err := renewScript.Run(ctx, n.rdb,
|
|
[]string{leaderPrefix + room}, n.ID, LeaseTTL.Milliseconds()).Int()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
held := res == 1
|
|
if !held {
|
|
n.mu.Lock()
|
|
delete(n.led, room)
|
|
n.mu.Unlock()
|
|
}
|
|
return held, nil
|
|
}
|
|
|
|
// releaseScript deletes the key only if we still own it, so a shutdown cannot
|
|
// release a lease that has already passed to another instance.
|
|
var releaseScript = redis.NewScript(`
|
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
return redis.call("DEL", KEYS[1])
|
|
end
|
|
return 0
|
|
`)
|
|
|
|
// Resign gives up leadership of a room immediately.
|
|
func (n *Node) Resign(ctx context.Context, room string) error {
|
|
n.mu.Lock()
|
|
delete(n.led, room)
|
|
n.mu.Unlock()
|
|
return releaseScript.Run(ctx, n.rdb,
|
|
[]string{leaderPrefix + room}, n.ID).Err()
|
|
}
|
|
|
|
// Leads reports whether this instance currently believes it leads a room.
|
|
func (n *Node) Leads(room string) bool {
|
|
n.mu.RLock()
|
|
defer n.mu.RUnlock()
|
|
return n.led[room]
|
|
}
|
|
|
|
// LeaderOf returns the instance leading a room, or an empty Member if the room
|
|
// is currently unled. Followers use this to forward mutations.
|
|
func (n *Node) LeaderOf(ctx context.Context, room string) (Member, error) {
|
|
id, err := n.rdb.Get(ctx, leaderPrefix+room).Result()
|
|
if errors.Is(err, redis.Nil) {
|
|
return Member{}, nil
|
|
}
|
|
if err != nil {
|
|
return Member{}, err
|
|
}
|
|
val, err := n.rdb.Get(ctx, memberPrefix+id).Result()
|
|
if errors.Is(err, redis.Nil) {
|
|
// The leader holds a lease but has stopped heartbeating; its lease
|
|
// will expire shortly and another instance will take over.
|
|
return Member{ID: id}, nil
|
|
}
|
|
if err != nil {
|
|
return Member{}, err
|
|
}
|
|
parts := strings.SplitN(val, "|", 4)
|
|
if len(parts) != 4 {
|
|
return Member{ID: id}, nil
|
|
}
|
|
return Member{ID: parts[0], Hostname: parts[1], Address: parts[2]}, nil
|
|
}
|
|
|
|
// PublishFrame sends a room frame to every instance. Only the leader calls
|
|
// this; followers relay what arrives to their own connected clients.
|
|
func (n *Node) PublishFrame(ctx context.Context, room string, payload []byte) error {
|
|
return n.rdb.Publish(ctx, framePrefix+room, payload).Err()
|
|
}
|
|
|
|
// SubscribeFrames returns a channel of frames for a room, published by
|
|
// whichever instance leads it.
|
|
func (n *Node) SubscribeFrames(ctx context.Context, room string) (<-chan []byte, func()) {
|
|
sub := n.rdb.Subscribe(ctx, framePrefix+room)
|
|
out := make(chan []byte, 8)
|
|
|
|
go func() {
|
|
defer close(out)
|
|
ch := sub.Channel()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case msg, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case out <- []byte(msg.Payload):
|
|
default: // relay is behind; drop rather than stall the room
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return out, func() { _ = sub.Close() }
|
|
}
|