Files
CosmicClash/server/store/server_connection_sql.go
T

79 lines
3.2 KiB
Go

package store
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
const ServerConnectionIdempotencyScope = "server.connection"
const ServerConnectionIdempotencyInsertSQL = `INSERT INTO idempotency_keys
(scope, idempotency_key, payload_digest, result)
VALUES ($1, $2, $3, '{}'::jsonb) ON CONFLICT (scope, idempotency_key) DO NOTHING`
const ServerConnectionIdempotencySelectSQL = `SELECT payload_digest
FROM idempotency_keys WHERE scope = $1 AND idempotency_key = $2 FOR UPDATE`
const ServerConnectionParticipantSQL = `UPDATE match_participants mp
SET connected_at = COALESCE(mp.connected_at, $5)
FROM matches m, allocations a, assignments assn
WHERE mp.match_id = $1 AND mp.player_id = $4 AND mp.participation_active
AND m.match_id = mp.match_id AND m.server_id = $2
AND m.state IN ('ASSIGNMENT_READY', 'ASSIGNED', 'CONNECTING', 'LIVE')
AND a.allocation_id = $3 AND a.match_id = m.match_id AND a.server_id = m.server_id
AND a.state = 'ALLOCATED'
AND assn.match_id = mp.match_id AND assn.player_id = mp.player_id
AND assn.allocation_id = a.allocation_id AND assn.server_id = m.server_id
AND assn.expires_at > $5
RETURNING mp.connected_at`
// RecordPlayerConnected persists authoritative admission observed by the
// allocated game server. The workload allocation, match/server binding,
// active participant, and still-live assignment must all agree.
func RecordPlayerConnected(ctx context.Context, db *sql.DB, binding domain.WorkloadBinding, playerID, idempotencyKey string, now time.Time) error {
if db == nil || binding.AllocationID == "" || binding.MatchID == "" || binding.ServerID == "" || playerID == "" || len(idempotencyKey) < 16 || len(idempotencyKey) > 128 || now.IsZero() {
return fmt.Errorf("invalid server connection receipt")
}
digest := sha256.Sum256([]byte(binding.AllocationID + "\x00" + binding.MatchID + "\x00" + binding.ServerID + "\x00" + playerID))
return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
inserted, err := tx.ExecContext(ctx, ServerConnectionIdempotencyInsertSQL, ServerConnectionIdempotencyScope, idempotencyKey, digest[:])
if err != nil {
return err
}
changed, err := inserted.RowsAffected()
if err != nil {
return err
}
if changed == 0 {
var prior []byte
if err := tx.QueryRowContext(ctx, ServerConnectionIdempotencySelectSQL, ServerConnectionIdempotencyScope, idempotencyKey).Scan(&prior); err != nil {
return err
}
if !bytes.Equal(prior, digest[:]) {
return domain.ErrConflict
}
return nil
}
var connectedAt time.Time
if err := tx.QueryRowContext(ctx, ServerConnectionParticipantSQL, binding.MatchID, binding.ServerID, binding.AllocationID, playerID, now).Scan(&connectedAt); err != nil {
if err == sql.ErrNoRows {
return domain.ErrConflict
}
return err
}
result, err := json.Marshal(map[string]any{"match_id": binding.MatchID, "player_id": playerID, "connected_at": connectedAt})
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `UPDATE idempotency_keys SET result = $3 WHERE scope = $1 AND idempotency_key = $2`, ServerConnectionIdempotencyScope, idempotencyKey, result)
return err
})
}