557 lines
15 KiB
Go
557 lines
15 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"database/sql/driver"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
const (
|
|
writeQueueCapacity = 1000
|
|
flushTimeout = 5 * time.Second
|
|
queryTimeout = 5 * time.Second
|
|
writeTimeout = 10 * time.Second
|
|
migrationTimeout = time.Minute
|
|
|
|
// A transient failure (PostgreSQL restart, dropped connection) is retried
|
|
// before storage degrades. Backoff doubles from writeRetryBase, bounding
|
|
// the stall of the ordered writer to roughly three seconds.
|
|
writeAttempts = 6
|
|
writeRetryBase = 100 * time.Millisecond
|
|
|
|
maxOpenConns = 10
|
|
maxIdleConns = 5
|
|
|
|
// schemaLockKey serializes migrations between the server and the CLI.
|
|
// Advisory locks are scoped to the current database.
|
|
schemaLockKey int64 = 0x6368657373 // "chess"
|
|
)
|
|
|
|
var (
|
|
ErrStorageDegraded = errors.New("storage is degraded")
|
|
ErrStoreClosed = errors.New("storage is closed")
|
|
ErrWriteQueueFull = errors.New("storage write queue is full")
|
|
)
|
|
|
|
type writeRequest struct {
|
|
operation string
|
|
gameID string
|
|
run func(context.Context, *sql.Tx) error
|
|
barrier chan error
|
|
}
|
|
|
|
// Store handles PostgreSQL operations with ordered async writes for gameplay
|
|
// and synchronous writes for accounts and sessions.
|
|
type Store struct {
|
|
db *sql.DB
|
|
writeChan chan writeRequest
|
|
healthStatus atomic.Bool
|
|
writeFailed atomic.Bool
|
|
closed atomic.Bool
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
enqueueMu sync.RWMutex
|
|
closeOnce sync.Once
|
|
closeErr error
|
|
gameMissing atomic.Pointer[func(gameID string)]
|
|
}
|
|
|
|
// SetGameMissingHandler registers the callback run, on its own goroutine,
|
|
// when a write finds its game row gone. Such a write is dropped without
|
|
// degrading storage: later writes for that game fail the same way and write
|
|
// nothing, so no other game's history is affected.
|
|
func (s *Store) SetGameMissingHandler(handler func(gameID string)) {
|
|
s.gameMissing.Store(&handler)
|
|
}
|
|
|
|
// NewStore connects to PostgreSQL and starts the async writer. dsn is a
|
|
// libpq-style keyword/value string or postgres:// URL; unset fields fall back
|
|
// to the standard PG* environment variables and ~/.pgpass. The schema is
|
|
// resolved through search_path, so the role's default or a search_path DSN
|
|
// parameter selects it.
|
|
func NewStore(dsn string) (*Store, error) {
|
|
config, err := pgx.ParseConfig(dsn)
|
|
if err != nil {
|
|
// Parse errors may echo the DSN, which can contain a password.
|
|
return nil, errors.New("invalid database connection string")
|
|
}
|
|
if _, ok := config.RuntimeParams["application_name"]; !ok {
|
|
config.RuntimeParams["application_name"] = "chess-server"
|
|
}
|
|
|
|
db := stdlib.OpenDB(*config)
|
|
db.SetMaxOpenConns(maxOpenConns)
|
|
db.SetMaxIdleConns(maxIdleConns)
|
|
db.SetConnMaxIdleTime(5 * time.Minute)
|
|
db.SetConnMaxLifetime(30 * time.Minute)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), queryTimeout)
|
|
defer cancel()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
|
}
|
|
|
|
writerCtx, writerCancel := context.WithCancel(context.Background())
|
|
s := &Store{
|
|
db: db,
|
|
writeChan: make(chan writeRequest, writeQueueCapacity),
|
|
ctx: writerCtx,
|
|
cancel: writerCancel,
|
|
}
|
|
s.healthStatus.Store(true)
|
|
|
|
s.wg.Add(1)
|
|
go s.writerLoop()
|
|
slog.Debug("storage opened",
|
|
"host", config.Host,
|
|
"database", config.Database,
|
|
"user", config.User,
|
|
"write_queue_capacity", writeQueueCapacity,
|
|
"max_open_connections", maxOpenConns,
|
|
)
|
|
return s, nil
|
|
}
|
|
|
|
// IsHealthy returns true if the storage is operational
|
|
func (s *Store) IsHealthy() bool {
|
|
return s.healthStatus.Load()
|
|
}
|
|
|
|
// writerLoop processes async write operations
|
|
func (s *Store) writerLoop() {
|
|
defer s.wg.Done()
|
|
slog.Debug("storage writer started")
|
|
defer slog.Debug("storage writer stopped")
|
|
|
|
for {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
// Every accepted operation is drained before shutdown. The queue is
|
|
// bounded, so shutdown remains bounded by actual database work rather
|
|
// than an arbitrary timer that can discard replay history.
|
|
for {
|
|
select {
|
|
case req := <-s.writeChan:
|
|
s.handleWrite(req)
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
|
|
case req := <-s.writeChan:
|
|
s.handleWrite(req)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Store) handleWrite(req writeRequest) {
|
|
if req.run == nil {
|
|
if req.barrier != nil {
|
|
var err error
|
|
if !s.healthStatus.Load() {
|
|
err = ErrStorageDegraded
|
|
}
|
|
req.barrier <- err
|
|
close(req.barrier)
|
|
}
|
|
return
|
|
}
|
|
|
|
if s.writeFailed.Load() {
|
|
slog.Error("storage write skipped after earlier transaction failure",
|
|
"operation", req.operation, "game_id", req.gameID)
|
|
if req.barrier != nil {
|
|
req.barrier <- ErrStorageDegraded
|
|
close(req.barrier)
|
|
}
|
|
return
|
|
}
|
|
|
|
err := s.executeWrite(req)
|
|
if req.barrier != nil {
|
|
req.barrier <- err
|
|
close(req.barrier)
|
|
}
|
|
}
|
|
|
|
// executeWrite runs one logical mutation as one transaction. A transient error
|
|
// raised before COMMIT is retried: the transaction was rolled back, so a retry
|
|
// cannot duplicate it, and the single writer preserves ordering while it waits.
|
|
// A COMMIT error has an unknown outcome and is never retried. Any unrecovered
|
|
// failure degrades storage permanently, because skipping one write would leave
|
|
// later writes applied to an incomplete history.
|
|
func (s *Store) executeWrite(req writeRequest) error {
|
|
started := time.Now()
|
|
var err error
|
|
for attempt := 1; ; attempt++ {
|
|
var committing bool
|
|
committing, err = s.runWriteTx(req.run)
|
|
if err == nil {
|
|
slog.Debug("storage write committed",
|
|
"operation", req.operation,
|
|
"game_id", req.gameID,
|
|
"attempts", attempt,
|
|
"duration", time.Since(started),
|
|
"queue_depth", len(s.writeChan),
|
|
)
|
|
return nil
|
|
}
|
|
if errors.Is(err, ErrGameMissing) {
|
|
slog.Warn("storage write dropped: game row no longer exists",
|
|
"operation", req.operation, "game_id", req.gameID)
|
|
if handler := s.gameMissing.Load(); handler != nil && req.gameID != "" {
|
|
go (*handler)(req.gameID)
|
|
}
|
|
return err
|
|
}
|
|
if committing || attempt >= writeAttempts || !isTransient(err) {
|
|
break
|
|
}
|
|
backoff := writeRetryBase << (attempt - 1)
|
|
slog.Warn("storage write failed transiently; retrying",
|
|
"operation", req.operation, "game_id", req.gameID,
|
|
"attempt", attempt, "backoff", backoff, "error", err)
|
|
time.Sleep(backoff)
|
|
}
|
|
|
|
s.writeFailed.Store(true)
|
|
s.healthStatus.Store(false)
|
|
slog.Error("storage degraded: write operation failed",
|
|
"operation", req.operation, "game_id", req.gameID, "error", err)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) runWriteTx(run func(context.Context, *sql.Tx) error) (committing bool, err error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
|
defer cancel()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return false, fmt.Errorf("begin transaction: %w", err)
|
|
}
|
|
if err := run(ctx, tx); err != nil {
|
|
_ = tx.Rollback()
|
|
return false, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return true, fmt.Errorf("commit transaction: %w", err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (s *Store) enqueue(operation, gameID string, fn func(context.Context, *sql.Tx) error) error {
|
|
s.enqueueMu.RLock()
|
|
defer s.enqueueMu.RUnlock()
|
|
|
|
if s.closed.Load() {
|
|
return ErrStoreClosed
|
|
}
|
|
if !s.healthStatus.Load() {
|
|
return ErrStorageDegraded
|
|
}
|
|
|
|
select {
|
|
case s.writeChan <- writeRequest{operation: operation, gameID: gameID, run: fn}:
|
|
slog.Debug("storage write queued",
|
|
"operation", operation,
|
|
"game_id", gameID,
|
|
"queue_depth", len(s.writeChan),
|
|
)
|
|
return nil
|
|
default:
|
|
s.healthStatus.Store(false)
|
|
slog.Error("storage degraded: write queue full",
|
|
"operation", operation,
|
|
"game_id", gameID,
|
|
"queue_capacity", cap(s.writeChan),
|
|
)
|
|
return ErrWriteQueueFull
|
|
}
|
|
}
|
|
|
|
// enqueueWait queues fn on the ordered writer and waits for its own outcome,
|
|
// for a write that must be ordered after queued gameplay writes and must also
|
|
// report success only once it is durable.
|
|
func (s *Store) enqueueWait(ctx context.Context, operation string, fn func(context.Context, *sql.Tx) error) error {
|
|
s.enqueueMu.RLock()
|
|
if s.closed.Load() {
|
|
s.enqueueMu.RUnlock()
|
|
return ErrStoreClosed
|
|
}
|
|
if !s.healthStatus.Load() {
|
|
s.enqueueMu.RUnlock()
|
|
return ErrStorageDegraded
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
select {
|
|
case s.writeChan <- writeRequest{operation: operation, run: fn, barrier: done}:
|
|
s.enqueueMu.RUnlock()
|
|
case <-ctx.Done():
|
|
s.enqueueMu.RUnlock()
|
|
return ctx.Err()
|
|
}
|
|
|
|
select {
|
|
case err := <-done:
|
|
return err
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Flush waits until every write queued before this call has completed. Replay
|
|
// reads use this barrier to provide read-after-write consistency while normal
|
|
// gameplay retains the low-latency async write path.
|
|
func (s *Store) Flush(ctx context.Context) error {
|
|
s.enqueueMu.RLock()
|
|
if s.closed.Load() {
|
|
s.enqueueMu.RUnlock()
|
|
return ErrStoreClosed
|
|
}
|
|
if !s.healthStatus.Load() {
|
|
s.enqueueMu.RUnlock()
|
|
return ErrStorageDegraded
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
select {
|
|
case s.writeChan <- writeRequest{operation: "flush", barrier: done}:
|
|
s.enqueueMu.RUnlock()
|
|
case <-ctx.Done():
|
|
s.enqueueMu.RUnlock()
|
|
return ctx.Err()
|
|
}
|
|
|
|
select {
|
|
case err := <-done:
|
|
return err
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
func (s *Store) flushBeforeRead() error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), flushTimeout)
|
|
defer cancel()
|
|
return s.Flush(ctx)
|
|
}
|
|
|
|
// Close drains accepted writes and closes the connection pool.
|
|
func (s *Store) Close() error {
|
|
s.closeOnce.Do(func() {
|
|
s.enqueueMu.Lock()
|
|
s.closed.Store(true)
|
|
s.cancel()
|
|
s.enqueueMu.Unlock()
|
|
|
|
s.wg.Wait()
|
|
if s.db != nil {
|
|
s.closeErr = s.db.Close()
|
|
}
|
|
})
|
|
return s.closeErr
|
|
}
|
|
|
|
// InitDB applies pending migrations. It holds a transaction-scoped advisory
|
|
// lock so concurrent migrators serialize, and issues no DDL when the schema is
|
|
// current: a runtime role limited to DML can start the server once an owner
|
|
// role has migrated the schema.
|
|
func (s *Store) InitDB() error {
|
|
started := time.Now()
|
|
ctx, cancel := context.WithTimeout(context.Background(), migrationTimeout)
|
|
defer cancel()
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, schemaLockKey); err != nil {
|
|
return fmt.Errorf("failed to lock schema: %w", err)
|
|
}
|
|
current, err := readSchemaVersion(ctx, tx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current > schemaVersion {
|
|
return fmt.Errorf("database schema version %d is newer than supported version %d",
|
|
current, schemaVersion)
|
|
}
|
|
if current == schemaVersion {
|
|
slog.Debug("storage schema current", "version", current)
|
|
return tx.Commit()
|
|
}
|
|
|
|
for version := current + 1; version <= schemaVersion; version++ {
|
|
if _, err := tx.ExecContext(ctx, migrations[version-1]); err != nil {
|
|
return fmt.Errorf("failed to apply schema version %d: %w", version, err)
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO schema_version (version) VALUES ($1)
|
|
ON CONFLICT (singleton) DO UPDATE SET version = excluded.version, updated_at = now()`,
|
|
schemaVersion,
|
|
); err != nil {
|
|
return fmt.Errorf("failed to record schema version: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("failed to commit schema: %w", err)
|
|
}
|
|
slog.Info("storage schema migrated", "from", current, "to", schemaVersion,
|
|
"duration", time.Since(started))
|
|
return nil
|
|
}
|
|
|
|
// SchemaVersion reports the version recorded in the database; 0 means no
|
|
// schema has been created in the current search_path.
|
|
func (s *Store) SchemaVersion() (int, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), queryTimeout)
|
|
defer cancel()
|
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
return readSchemaVersion(ctx, tx)
|
|
}
|
|
|
|
func readSchemaVersion(ctx context.Context, tx *sql.Tx) (int, error) {
|
|
var exists bool
|
|
if err := tx.QueryRowContext(ctx,
|
|
`SELECT to_regclass('schema_version') IS NOT NULL`,
|
|
).Scan(&exists); err != nil {
|
|
return 0, fmt.Errorf("failed to inspect schema: %w", err)
|
|
}
|
|
if !exists {
|
|
return 0, nil
|
|
}
|
|
var version int
|
|
err := tx.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&version)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, nil
|
|
}
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to read schema version: %w", err)
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
// DropSchema removes every chess table from the current search_path. It is
|
|
// destructive and requires ownership of the tables.
|
|
func (s *Store) DropSchema() error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), migrationTimeout)
|
|
defer cancel()
|
|
// RESTRICT (the default) refuses to drop objects that others depend on,
|
|
// such as an administrator's reporting view.
|
|
_, err := s.db.ExecContext(ctx, `DROP TABLE IF EXISTS `+strings.Join(ownedTables, ", "))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to drop schema: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// opContext bounds a synchronous operation so a stalled database cannot hold a
|
|
// request handler indefinitely.
|
|
func opContext() (context.Context, context.CancelFunc) {
|
|
return context.WithTimeout(context.Background(), queryTimeout)
|
|
}
|
|
|
|
// isTransient reports errors after which the same transaction may succeed on a
|
|
// fresh connection.
|
|
func isTransient(err error) bool {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) {
|
|
switch pgErr.Code {
|
|
case "40001", // serialization_failure
|
|
"40P01", // deadlock_detected
|
|
"57P01", // admin_shutdown
|
|
"57P02", // crash_shutdown
|
|
"57P03": // cannot_connect_now
|
|
return true
|
|
}
|
|
return strings.HasPrefix(pgErr.Code, "08") // connection_exception class
|
|
}
|
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
return false
|
|
}
|
|
var connectErr *pgconn.ConnectError
|
|
var netErr net.Error
|
|
return errors.As(err, &connectErr) ||
|
|
errors.As(err, &netErr) ||
|
|
errors.Is(err, driver.ErrBadConn) ||
|
|
errors.Is(err, io.EOF) ||
|
|
errors.Is(err, io.ErrUnexpectedEOF) ||
|
|
pgconn.SafeToRetry(err)
|
|
}
|
|
|
|
// uniqueViolation returns the violated constraint name for SQLSTATE 23505.
|
|
func uniqueViolation(err error) (string, bool) {
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
return pgErr.ConstraintName, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// validUUID accepts the canonical 36-character form. Lookups treat anything
|
|
// else as not found instead of sending PostgreSQL a value that fails to cast.
|
|
func validUUID(value string) bool {
|
|
if len(value) != 36 {
|
|
return false
|
|
}
|
|
_, err := uuid.Parse(value)
|
|
return err == nil
|
|
}
|
|
|
|
func nullableString(value string) any {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
func utcPointer(value sql.NullTime) *time.Time {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
t := value.Time.UTC()
|
|
return &t
|
|
}
|
|
|
|
// ErrSchemaMismatch reports a database whose schema is absent or at a version
|
|
// other than the one this binary supports.
|
|
var ErrSchemaMismatch = errors.New("database schema version mismatch")
|
|
|
|
// CheckSchema verifies that the schema is current without changing it.
|
|
func (s *Store) CheckSchema() error {
|
|
version, err := s.SchemaVersion()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch {
|
|
case version == 0:
|
|
return fmt.Errorf("%w: schema not initialized (run 'chess-server db init')", ErrSchemaMismatch)
|
|
case version != schemaVersion:
|
|
return fmt.Errorf("%w: database has version %d, binary supports %d",
|
|
ErrSchemaMismatch, version, schemaVersion)
|
|
}
|
|
return nil
|
|
}
|