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:
127
docs/SCALING.md
Normal file
127
docs/SCALING.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Scaling by cloning
|
||||
|
||||
The app is stateless. To serve more players, clone the app VM and boot it.
|
||||
An instance works out what it is on startup: it generates its own identity,
|
||||
registers itself, and negotiates which games it drives. Nothing is assigned by
|
||||
hand, and no file needs editing after a clone.
|
||||
|
||||
## What runs where
|
||||
|
||||
| VM | Runs | How many |
|
||||
|---|---|---|
|
||||
| **core** | PostgreSQL + Redis + Caddy | exactly one |
|
||||
| **app** | `quantum-arcade` | **clone this one** |
|
||||
| **lightning** | Alby Hub | one, firewalled |
|
||||
|
||||
**Do not clone the core VM.** If each app clone brings its own PostgreSQL and
|
||||
Redis, the clones share nothing: separate ledgers, separate rounds, mutually
|
||||
invisible. The app VM must contain *only* the arcade binary.
|
||||
|
||||
Keep Alby Hub separate from the app. The app VMs are what every phone talks to;
|
||||
the Lightning node holds keys and channel state. Separation is what makes a
|
||||
compromised app instance survivable — it holds a budget-capped credential, not
|
||||
the node.
|
||||
|
||||
## Configuring a clone
|
||||
|
||||
Two variables, both pointing at the core VM:
|
||||
|
||||
```bash
|
||||
ARCADE_DSN=postgres://arcade:PASSWORD@10.0.0.10:5432/arcade
|
||||
ARCADE_REDIS=10.0.0.10:6379
|
||||
```
|
||||
|
||||
Optionally, if the instance's routable address cannot be detected (multiple
|
||||
NICs, NAT):
|
||||
|
||||
```bash
|
||||
ARCADE_ADVERTISE=10.0.0.21:8080
|
||||
```
|
||||
|
||||
Otherwise it advertises the first non-loopback IPv4 address it finds, which is
|
||||
correct on a normal Proxmox bridge with DHCP.
|
||||
|
||||
Everything else — instance id, which games it drives, which peers exist — is
|
||||
determined at runtime.
|
||||
|
||||
## How instances divide the work
|
||||
|
||||
Each game is driven by exactly one instance at a time.
|
||||
|
||||
- On startup an instance **campaigns** for each game: a Redis key set with
|
||||
`SET NX PX`, held for `LeaseTTL` (6s) and renewed every 2s.
|
||||
- The winner runs that game's round loop, settles to the ledger, and publishes
|
||||
every frame to Redis.
|
||||
- Every other instance **relays** those frames to its own connected clients.
|
||||
A client cannot tell which instance it is attached to.
|
||||
- Bets and cash-outs arriving at a non-leader are **forwarded** to the leader,
|
||||
because only the leader holds the authoritative round state. Sessions live in
|
||||
Redis, so a token issued anywhere is accepted everywhere and the forwarded
|
||||
request authenticates normally.
|
||||
|
||||
Leadership spreads itself across instances naturally: whichever instance
|
||||
campaigns first for a given game gets it, so three games across two instances
|
||||
lands roughly 2/1.
|
||||
|
||||
## Failure
|
||||
|
||||
An instance dying is not a special case. Its lease stops being renewed, expires
|
||||
within `LeaseTTL`, and the next campaign hands its games to a survivor.
|
||||
|
||||
Measured with a hard `kill -9` on an instance leading two of three games:
|
||||
|
||||
```
|
||||
t+0s killed
|
||||
t+6s both games taken over, rounds running
|
||||
```
|
||||
|
||||
Six seconds, unattended. Players attached to the dead instance reconnect
|
||||
through the load balancer and rejoin whichever instance answers.
|
||||
|
||||
The in-flight round on the dead instance is lost — bets already written to the
|
||||
ledger stand, and the round simply never settles. This is the one rough edge:
|
||||
stakes are debited at bet time, so a round lost mid-flight leaves those stakes
|
||||
with the house. A reconciliation job that refunds unsettled rounds is not yet
|
||||
built.
|
||||
|
||||
## Load balancing
|
||||
|
||||
Caddy needs no sticky sessions — any instance serves any request.
|
||||
|
||||
```
|
||||
arcade.lan {
|
||||
reverse_proxy 10.0.0.21:8080 10.0.0.22:8080 10.0.0.23:8080 {
|
||||
lb_policy least_conn
|
||||
health_uri /api/health
|
||||
health_interval 5s
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`least_conn` suits long-lived WebSockets better than round-robin, which
|
||||
distributes connection *attempts* rather than connections.
|
||||
|
||||
## Watching the fleet
|
||||
|
||||
```bash
|
||||
curl -s http://arcade.lan/api/cluster | jq
|
||||
```
|
||||
|
||||
Returns every registered instance, which one drives each game, and which
|
||||
instance answered. Useful for confirming a clone joined, and for watching
|
||||
leadership move during a failover.
|
||||
|
||||
## Where this stops scaling
|
||||
|
||||
Adding app clones raises the ceiling on connections and fan-out. It does not
|
||||
raise these:
|
||||
|
||||
- **Bet throughput**, measured at ~230/sec, is bounded by PostgreSQL commit
|
||||
cost. Every clone contends for the same database. Getting past this needs
|
||||
in-memory balance reservation with batched persistence — a change to how
|
||||
money is held, not a deployment change.
|
||||
- **A single game's round loop** runs on one instance, by design. A game cannot
|
||||
be split across instances without a distributed clock.
|
||||
|
||||
So: clone freely for more spectators and more connections. For more *bets per
|
||||
second*, the database is the thing to work on.
|
||||
Reference in New Issue
Block a user