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>
This commit is contained in:
95
cmd/arcade/forward.go
Normal file
95
cmd/arcade/forward.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// forwardClient is deliberately short-tempered. A bet or cash-out is only
|
||||
// useful while the round is still open, so a request that cannot be delivered
|
||||
// promptly should fail loudly rather than land after the moment has passed.
|
||||
var forwardClient = &http.Client{Timeout: 3 * time.Second}
|
||||
|
||||
// forwardToLeader proxies a mutation to whichever instance drives the game.
|
||||
//
|
||||
// Only one instance holds a game's authoritative round state — who is in, at
|
||||
// what stake, and at which tick each cash-out landed. Serving a bet from a
|
||||
// follower's idle copy would either fail or, worse, create a second version of
|
||||
// the round. So the follower relays the request and returns the leader's
|
||||
// answer verbatim.
|
||||
//
|
||||
// The caller's Authorization header travels with it. That works because
|
||||
// sessions live in Redis, so the leader can validate a token issued by any
|
||||
// instance in the fleet.
|
||||
//
|
||||
// It reports whether the request was handled here.
|
||||
func (s *server) forwardToLeader(w http.ResponseWriter, r *http.Request, game string, body []byte) bool {
|
||||
hub, ok := s.hubs[game]
|
||||
if !ok {
|
||||
writeErr(w, http.StatusNotFound, "no such game")
|
||||
return true
|
||||
}
|
||||
if hub.Leading() {
|
||||
return false // this instance owns the round; handle it locally
|
||||
}
|
||||
|
||||
leader, err := s.node.LeaderOf(r.Context(), game)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "cannot locate the game leader")
|
||||
return true
|
||||
}
|
||||
if leader.Address == "" {
|
||||
// Between leaders: a lease has expired and the next campaign has not
|
||||
// landed yet. This resolves within a couple of seconds on its own.
|
||||
writeErr(w, http.StatusServiceUnavailable,
|
||||
"this game is changing hands; try again in a moment")
|
||||
return true
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://%s%s", leader.Address, r.URL.Path)
|
||||
req, err := http.NewRequestWithContext(r.Context(), r.Method, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return true
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||
req.Header.Set("Authorization", auth)
|
||||
}
|
||||
// Mark the hop so a routing mistake shows up as an explicit loop error
|
||||
// rather than as instances bouncing a request between themselves.
|
||||
if r.Header.Get("X-Arcade-Forwarded") != "" {
|
||||
writeErr(w, http.StatusLoopDetected,
|
||||
"request was forwarded twice; the cluster disagrees about the leader")
|
||||
return true
|
||||
}
|
||||
req.Header.Set("X-Arcade-Forwarded", s.node.ID)
|
||||
|
||||
res, err := forwardClient.Do(req)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusServiceUnavailable,
|
||||
"the instance running this game did not respond")
|
||||
return true
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
payload, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadGateway, "truncated response from the game leader")
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(res.StatusCode)
|
||||
_, _ = w.Write(payload)
|
||||
return true
|
||||
}
|
||||
|
||||
// readBody buffers a request body so it can be both parsed locally and
|
||||
// forwarded if this instance turns out not to own the game.
|
||||
func readBody(r *http.Request) ([]byte, error) {
|
||||
defer r.Body.Close()
|
||||
return io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
}
|
||||
Reference in New Issue
Block a user