fix(matchmaking): re-enqueue opens a new game, not the caller's own
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s

A second "random opponent" enqueue with the same variant and per-turn
rule, while the caller's first game was still open (awaiting an
opponent), returned that same open game, so a player could never start a
fresh random game while one was still searching.

Drop the own-open short-circuit (step 1) in store.OpenOrJoin: a
re-enqueue now joins another player's open game or opens a fresh one.
Accumulation stays bounded by MaxActiveQuickGames, which counts open
games. Update the matchmaker/service/store doc comments and
ARCHITECTURE.md, and flip the pinning test to assert the new behavior.
This commit is contained in:
Ilia Denisov
2026-06-18 10:18:44 +02:00
parent 8793bd34f2
commit 9d52885a6e
5 changed files with 39 additions and 40 deletions
+14 -26
View File
@@ -188,37 +188,25 @@ func openMatchKey(variant string, multipleWords bool) int64 {
}
// OpenOrJoin atomically resolves an auto-match enqueue for accountID into the game it
// lands in: it re-uses the caller's own still-open game (joined=false, created=false,
// a re-enqueue is idempotent), joins another player's waiting open game and flips it
// active (joined=true), or opens a fresh game seating the caller with an empty
// opponent seat (created=true). ins supplies the new game's immutable fields and is
// used only when a game is created. A transaction-scoped advisory lock on the
// (variant, rule) bucket serialises concurrent enqueues so two callers pair rather
// than each opening a game. seats is the two-seat arrangement (the caller and uuid.Nil
// for the still-empty opponent, in the chosen order) used only when a game is created;
// callerName is the caller's display-name snapshot, stamped on their seat whether they
// open a fresh game or fill another player's open one.
// lands in: it joins another player's waiting open game and flips it active
// (joined=true), or opens a fresh game seating the caller with an empty opponent seat
// (created=true). A re-enqueue while the caller already has an open game in the bucket
// opens another fresh game (or joins a different player's) rather than returning the
// caller's own, so tapping "random opponent" again always starts a new search. ins
// supplies the new game's immutable fields and is used only when a game is created. A
// transaction-scoped advisory lock on the (variant, rule) bucket serialises concurrent
// enqueues so two callers pair rather than each opening a game. seats is the two-seat
// arrangement (the caller and uuid.Nil for the still-empty opponent, in the chosen
// order) used only when a game is created; callerName is the caller's display-name
// snapshot, stamped on their seat whether they open a fresh game or fill another
// player's open one.
func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName string, ins gameInsert, seats []seatInsert) (gameID uuid.UUID, joined, created bool, err error) {
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if _, e := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`,
openMatchKey(ins.variant, ins.multipleWordsPerTurn)); e != nil {
return fmt.Errorf("open match lock: %w", e)
}
// 1. The caller's own still-open game for this bucket — a re-enqueue is idempotent.
var own uuid.UUID
switch e := tx.QueryRowContext(ctx,
`SELECT g.game_id FROM backend.games g
JOIN backend.game_players p ON p.game_id = g.game_id
WHERE g.status = 'open' AND g.variant = $1 AND g.multiple_words_per_turn = $2 AND p.account_id = $3
LIMIT 1`,
ins.variant, ins.multipleWordsPerTurn, accountID).Scan(&own); {
case e == nil:
gameID = own
return nil
case !errors.Is(e, sql.ErrNoRows):
return fmt.Errorf("find own open game: %w", e)
}
// 2. Another player's open game waiting for an opponent — fill its seat and start it.
// 1. Another player's open game waiting for an opponent — fill its seat and start it.
var other uuid.UUID
switch e := tx.QueryRowContext(ctx,
`SELECT g.game_id FROM backend.games g
@@ -237,7 +225,7 @@ func (s *Store) OpenOrJoin(ctx context.Context, accountID uuid.UUID, callerName
case !errors.Is(e, sql.ErrNoRows):
return fmt.Errorf("find open game: %w", e)
}
// 3. None waiting — open a fresh game seating the caller (the other seat empty).
// 2. None waiting — open a fresh game seating the caller (the other seat empty).
if e := insertGameTx(ctx, tx, ins, seats); e != nil {
return e
}