refactor: deduplicate persistence, add upsert queries, throttle snake saves
Some checks failed
CI / Deploy / test (pull_request) Failing after 15s
CI / Deploy / lint (pull_request) Failing after 23s
CI / Deploy / deploy (pull_request) Has been skipped

- Replace Create+Get+Update with UpsertGame/UpsertSnakeGame queries
- Extract free functions (saveGame, loadGame, etc.) from duplicated
  receiver methods on Store and Instance types
- Remove duplicate generateID from snake package, reuse game.GenerateID
- Throttle snake game DB writes to every 2s instead of every tick
- Fix double-lock in c4game chat handler
- Update all code for sqlc pointer types (*string instead of sql.NullString)
This commit is contained in:
Ryan Hamamura
2026-03-02 16:56:29 -10:00
parent 9c3f659e96
commit bc6488f063
14 changed files with 318 additions and 494 deletions

View File

@@ -62,16 +62,14 @@ func (si *SnakeGameInstance) countdownPhase() {
si.game.Status = StatusInProgress
if si.queries != nil {
si.saveSnakeGame(si.game) //nolint:errcheck
saveSnakeGame(si.queries, si.game) //nolint:errcheck
}
si.gameMu.Unlock()
si.notify()
return
}
if si.queries != nil {
si.saveSnakeGame(si.game) //nolint:errcheck
}
// No DB save during countdown ticks — state is transient
si.gameMu.Unlock()
si.notify()
}
@@ -98,6 +96,7 @@ func (si *SnakeGameInstance) gamePhase() {
defer ticker.Stop()
lastInput := time.Now()
lastSave := time.Now()
var moveAccum time.Duration
for {
@@ -124,7 +123,7 @@ func (si *SnakeGameInstance) gamePhase() {
if time.Since(lastInput) > inactivityLimit {
si.game.Status = StatusFinished
if si.queries != nil {
si.saveSnakeGame(si.game) //nolint:errcheck
saveSnakeGame(si.queries, si.game) //nolint:errcheck
}
si.gameMu.Unlock()
si.notify()
@@ -175,13 +174,10 @@ func (si *SnakeGameInstance) gamePhase() {
alive := AliveCount(state)
gameOver := false
if si.game.Mode == ModeSinglePlayer {
// Single player ends when the player dies (alive == 0)
if alive == 0 {
gameOver = true
// No winner in single player - just final score
}
} else {
// Multiplayer ends when 1 or fewer alive
if alive <= 1 {
gameOver = true
winnerIdx := LastAlive(state)
@@ -195,8 +191,10 @@ func (si *SnakeGameInstance) gamePhase() {
si.game.Status = StatusFinished
}
if si.queries != nil {
si.saveSnakeGame(si.game) //nolint:errcheck
// Throttle DB saves: persist on game over or every 2 seconds
if si.queries != nil && (gameOver || time.Since(lastSave) >= 2*time.Second) {
saveSnakeGame(si.queries, si.game) //nolint:errcheck
lastSave = time.Now()
}
si.gameMu.Unlock()

View File

@@ -2,108 +2,49 @@ package snake
import (
"context"
"database/sql"
"github.com/ryanhamamura/c4/db/repository"
)
// Persistence methods on SnakeStore (used during Get to hydrate from DB).
func (ss *SnakeStore) saveSnakeGame(sg *SnakeGame) error {
ctx := context.Background()
// saveSnakeGame persists the snake game state via upsert.
func saveSnakeGame(queries *repository.Queries, sg *SnakeGame) error {
boardJSON := "{}"
var gridWidth, gridHeight *int64
if sg.State != nil {
boardJSON = sg.State.ToJSON()
w, h := int64(sg.State.Width), int64(sg.State.Height)
gridWidth, gridHeight = &w, &h
}
var gridWidth, gridHeight sql.NullInt64
if sg.State != nil {
gridWidth = sql.NullInt64{Int64: int64(sg.State.Width), Valid: true}
gridHeight = sql.NullInt64{Int64: int64(sg.State.Height), Valid: true}
var winnerUserID *string
if sg.Winner != nil && sg.Winner.UserID != nil {
winnerUserID = sg.Winner.UserID
}
_, err := ss.queries.GetSnakeGame(ctx, sg.ID)
if err == sql.ErrNoRows {
_, err = ss.queries.CreateSnakeGame(ctx, repository.CreateSnakeGameParams{
ID: sg.ID,
Board: boardJSON,
Status: int64(sg.Status),
GridWidth: gridWidth,
GridHeight: gridHeight,
GameMode: int64(sg.Mode),
SnakeSpeed: int64(sg.Speed),
})
return err
}
if err != nil {
return err
}
return ss.queries.UpdateSnakeGame(ctx, updateSnakeGameParams(sg, boardJSON))
return queries.UpsertSnakeGame(context.Background(), repository.UpsertSnakeGameParams{
ID: sg.ID,
Board: boardJSON,
Status: int64(sg.Status),
GridWidth: gridWidth,
GridHeight: gridHeight,
GameMode: int64(sg.Mode),
SnakeSpeed: int64(sg.Speed),
WinnerUserID: winnerUserID,
RematchGameID: sg.RematchGameID,
Score: int64(sg.Score),
})
}
func (ss *SnakeStore) loadSnakeGame(id string) (*SnakeGame, error) {
row, err := ss.queries.GetSnakeGame(context.Background(), id)
if err != nil {
return nil, err
}
return snakeGameFromRow(row)
}
func (ss *SnakeStore) loadSnakePlayers(id string) ([]*Player, error) {
rows, err := ss.queries.GetSnakePlayers(context.Background(), id)
if err != nil {
return nil, err
}
return snakePlayersFromRows(rows), nil
}
// Persistence methods on SnakeGameInstance (used during gameplay mutations).
func (si *SnakeGameInstance) saveSnakeGame(sg *SnakeGame) error {
ctx := context.Background()
boardJSON := "{}"
if sg.State != nil {
boardJSON = sg.State.ToJSON()
}
var gridWidth, gridHeight sql.NullInt64
if sg.State != nil {
gridWidth = sql.NullInt64{Int64: int64(sg.State.Width), Valid: true}
gridHeight = sql.NullInt64{Int64: int64(sg.State.Height), Valid: true}
}
_, err := si.queries.GetSnakeGame(ctx, sg.ID)
if err == sql.ErrNoRows {
_, err = si.queries.CreateSnakeGame(ctx, repository.CreateSnakeGameParams{
ID: sg.ID,
Board: boardJSON,
Status: int64(sg.Status),
GridWidth: gridWidth,
GridHeight: gridHeight,
GameMode: int64(sg.Mode),
SnakeSpeed: int64(sg.Speed),
})
return err
}
if err != nil {
return err
}
return si.queries.UpdateSnakeGame(ctx, updateSnakeGameParams(sg, boardJSON))
}
func (si *SnakeGameInstance) saveSnakePlayer(gameID string, player *Player) error {
var userID, guestPlayerID sql.NullString
func saveSnakePlayer(queries *repository.Queries, gameID string, player *Player) error {
var userID, guestPlayerID *string
if player.UserID != nil {
userID = sql.NullString{String: *player.UserID, Valid: true}
userID = player.UserID
} else {
guestPlayerID = sql.NullString{String: string(player.ID), Valid: true}
id := string(player.ID)
guestPlayerID = &id
}
return si.queries.CreateSnakePlayer(context.Background(), repository.CreateSnakePlayerParams{
return queries.CreateSnakePlayer(context.Background(), repository.CreateSnakePlayerParams{
GameID: gameID,
UserID: userID,
GuestPlayerID: guestPlayerID,
@@ -113,39 +54,34 @@ func (si *SnakeGameInstance) saveSnakePlayer(gameID string, player *Player) erro
})
}
// Shared helpers for domain ↔ DB mapping.
func updateSnakeGameParams(sg *SnakeGame, boardJSON string) repository.UpdateSnakeGameParams {
var winnerUserID sql.NullString
if sg.Winner != nil && sg.Winner.UserID != nil {
winnerUserID = sql.NullString{String: *sg.Winner.UserID, Valid: true}
}
var rematchGameID sql.NullString
if sg.RematchGameID != nil {
rematchGameID = sql.NullString{String: *sg.RematchGameID, Valid: true}
}
return repository.UpdateSnakeGameParams{
Board: boardJSON,
Status: int64(sg.Status),
WinnerUserID: winnerUserID,
RematchGameID: rematchGameID,
Score: int64(sg.Score),
ID: sg.ID,
func loadSnakeGame(queries *repository.Queries, id string) (*SnakeGame, error) {
row, err := queries.GetSnakeGame(context.Background(), id)
if err != nil {
return nil, err
}
return snakeGameFromRow(row)
}
func snakeGameFromRow(row repository.Game) (*SnakeGame, error) {
func loadSnakePlayers(queries *repository.Queries, id string) ([]*Player, error) {
rows, err := queries.GetSnakePlayers(context.Background(), id)
if err != nil {
return nil, err
}
return snakePlayersFromRows(rows), nil
}
// Domain ↔ DB mapping helpers.
func snakeGameFromRow(row *repository.Game) (*SnakeGame, error) {
state, err := GameStateFromJSON(row.Board)
if err != nil {
state = &GameState{}
}
if row.GridWidth.Valid {
state.Width = int(row.GridWidth.Int64)
if row.GridWidth != nil {
state.Width = int(*row.GridWidth)
}
if row.GridHeight.Valid {
state.Height = int(row.GridHeight.Int64)
if row.GridHeight != nil {
state.Height = int(*row.GridHeight)
}
sg := &SnakeGame{
@@ -158,14 +94,14 @@ func snakeGameFromRow(row repository.Game) (*SnakeGame, error) {
Speed: int(row.SnakeSpeed),
}
if row.RematchGameID.Valid {
sg.RematchGameID = &row.RematchGameID.String
if row.RematchGameID != nil {
sg.RematchGameID = row.RematchGameID
}
return sg, nil
}
func snakePlayersFromRows(rows []repository.GamePlayer) []*Player {
func snakePlayersFromRows(rows []*repository.GamePlayer) []*Player {
players := make([]*Player, 0, len(rows))
for _, row := range rows {
player := &Player{
@@ -173,11 +109,11 @@ func snakePlayersFromRows(rows []repository.GamePlayer) []*Player {
Slot: int(row.Slot),
}
if row.UserID.Valid {
player.UserID = &row.UserID.String
player.ID = PlayerID(row.UserID.String)
} else if row.GuestPlayerID.Valid {
player.ID = PlayerID(row.GuestPlayerID.String)
if row.UserID != nil {
player.UserID = row.UserID
player.ID = PlayerID(*row.UserID)
} else if row.GuestPlayerID != nil {
player.ID = PlayerID(*row.GuestPlayerID)
}
players = append(players, player)

View File

@@ -2,11 +2,10 @@ package snake
import (
"context"
"crypto/rand"
"encoding/hex"
"sync"
"github.com/ryanhamamura/c4/db/repository"
"github.com/ryanhamamura/c4/game"
)
type SnakeStore struct {
@@ -39,7 +38,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
if speed <= 0 {
speed = DefaultSpeed
}
id := generateID(4)
id := game.GenerateID(4)
sg := &SnakeGame{
ID: id,
State: &GameState{
@@ -63,7 +62,7 @@ func (ss *SnakeStore) Create(width, height int, mode GameMode, speed int) *Snake
ss.gamesMu.Unlock()
if ss.queries != nil {
ss.saveSnakeGame(sg) //nolint:errcheck
saveSnakeGame(ss.queries, sg) //nolint:errcheck
}
return si
@@ -82,12 +81,12 @@ func (ss *SnakeStore) Get(id string) (*SnakeGameInstance, bool) {
return nil, false
}
sg, err := ss.loadSnakeGame(id)
sg, err := loadSnakeGame(ss.queries, id)
if err != nil || sg == nil {
return nil, false
}
players, _ := ss.loadSnakePlayers(id)
players, _ := loadSnakePlayers(ss.queries, id)
if sg.Players == nil {
sg.Players = make([]*Player, 8)
}
@@ -207,8 +206,8 @@ func (si *SnakeGameInstance) Join(player *Player) bool {
si.game.Players[slot] = player
if si.queries != nil {
si.saveSnakePlayer(si.game.ID, player) //nolint:errcheck
si.saveSnakeGame(si.game) //nolint:errcheck
saveSnakePlayer(si.queries, si.game.ID, player) //nolint:errcheck
saveSnakeGame(si.queries, si.game) //nolint:errcheck
}
si.notify()
@@ -294,16 +293,10 @@ func (si *SnakeGameInstance) CreateRematch() *SnakeGameInstance {
si.game.RematchGameID = &newID
if si.queries != nil {
si.saveSnakeGame(si.game) //nolint:errcheck
saveSnakeGame(si.queries, si.game) //nolint:errcheck
}
si.gameMu.Unlock()
si.notify()
return newSI
}
func generateID(size int) string {
b := make([]byte, size)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}