games: the felt other people can sit at, and the version that settles the race
Phase B foundation for the multiplayer casino: the shared-table storage layer, the SSE fan-out, and the lock that only ever pretends to be the authority. - game_tables/game_seats/game_chat, plus a nullable table_id on game_live_hands so occupancy stays one row per player — the same primary key that stops a second solo hand stops a second seat. No second uniqueness domain, no split brain, no cash-out-to-zero while sitting on a pot. - The money model the plan sketched turned out simpler than it drew: chips cross the border only at sit-down and get-up, so a hand settles by moving the pot *within* the state blob and credits nobody. That deletes the payout ledger the design called for — there is no money write to make idempotent, only a state write conditional on the version. A replayed settle affects zero rows. - CommitTable/SitDown/LeaveTable each one transaction with the state write in it; the version column is the concurrency authority and the striped in-memory lock is only an optimisation over it, because a mutex does not survive a redeploy. - The SSE hub is a dumb byte fan-out: non-blocking sends (a stalled phone must not hold the table lock and freeze the clock for the room) and never a DB touch after the first read (holding the one connection open bricks the app). - DueTables/PushDeadlines for the turn clock to come; Chat keeps the hand_no it was said during, because at a money table collusion looks like chat. Storage and hub tested, including the version race and the never-block publish. No handlers wired yet, so nothing a player can see has changed. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
58
internal/web/games_lock.go
Normal file
58
internal/web/games_lock.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"hash/fnv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The striped table lock, and why it is only ever an optimisation.
|
||||
//
|
||||
// The database's version column is the real concurrency authority: every write
|
||||
// to a table is conditional on the version the writer read, so two writers that
|
||||
// race produce one winner and one ErrStaleTable no matter what happens in
|
||||
// memory. This lock exists purely to make the loser lose *before* it does the
|
||||
// work, rather than after — it serialises the read-modify-write on a table so the
|
||||
// common case doesn't burn an engine step and a marshal only to be told it was
|
||||
// stale.
|
||||
//
|
||||
// It is a fixed array hashed on table id, never a map you can delete from, and
|
||||
// that is deliberate. A map of mutexes keyed by table id, cleaned up when a table
|
||||
// empties, will hand two goroutines two different mutex objects for the same
|
||||
// table across a delete-and-recreate — which is no lock at all. A fixed array has
|
||||
// no lifecycle: the same id always hashes to the same mutex, forever. The only
|
||||
// cost is that two unrelated tables can collide onto one stripe and briefly wait
|
||||
// on each other, which is harmless.
|
||||
//
|
||||
// A redeploy is the case that proves the version column has to be the authority:
|
||||
// during a drain two processes are running, each with its own array, so a table
|
||||
// is "locked" by two mutexes that know nothing about each other. The version
|
||||
// column is the only thing both processes share, and it is what keeps them
|
||||
// correct while the mutexes are useless.
|
||||
|
||||
// lockStripes is how many mutexes the array holds. A power of two so the mask is
|
||||
// clean; large enough that collisions between live tables are rare.
|
||||
const lockStripes = 256
|
||||
|
||||
type stripedLocks struct {
|
||||
m [lockStripes]sync.Mutex
|
||||
}
|
||||
|
||||
func newStripedLocks() *stripedLocks { return &stripedLocks{} }
|
||||
|
||||
// forTable returns the mutex a given table hashes onto. The same id always
|
||||
// returns the same mutex.
|
||||
func (s *stripedLocks) forTable(id string) *sync.Mutex {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(id))
|
||||
return &s.m[h.Sum32()&(lockStripes-1)]
|
||||
}
|
||||
|
||||
// withTable runs fn while holding the table's stripe. The lock is released when
|
||||
// fn returns — it never spans a network read or an SSE send, only the
|
||||
// read-modify-write against the database.
|
||||
func (s *stripedLocks) withTable(id string, fn func() error) error {
|
||||
mu := s.forTable(id)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return fn()
|
||||
}
|
||||
Reference in New Issue
Block a user