426 lines
10 KiB
Go
426 lines
10 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
const (
|
|
writeQueueCapacity = 1000
|
|
flushTimeout = 5 * time.Second
|
|
schemaVersion = 2
|
|
)
|
|
|
|
var memoryStoreCounter atomic.Uint64
|
|
|
|
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(*sql.Tx) error
|
|
barrier chan error
|
|
}
|
|
|
|
// Store handles SQLite database operations with async writes for games and sync writes for auth
|
|
type Store struct {
|
|
db *sql.DB
|
|
path string
|
|
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
|
|
}
|
|
|
|
// NewStore creates a new storage instance with async writer
|
|
func NewStore(dataSourceName string, devMode bool) (*Store, error) {
|
|
dsn := sqliteDSN(dataSourceName)
|
|
db, err := sql.Open("sqlite3", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
|
}
|
|
|
|
if err := db.Ping(); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
|
}
|
|
|
|
// SQLite benefits from a small pool. WAL and busy_timeout are configured in
|
|
// the DSN for every connection, unlike connection-local PRAGMA calls.
|
|
db.SetMaxOpenConns(8)
|
|
db.SetMaxIdleConns(4)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
s := &Store{
|
|
db: db,
|
|
path: dataSourceName,
|
|
writeChan: make(chan writeRequest, writeQueueCapacity),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
}
|
|
|
|
// Initialize health as true
|
|
s.healthStatus.Store(true)
|
|
|
|
// Start async writer
|
|
s.wg.Add(1)
|
|
go s.writerLoop()
|
|
slog.Debug("storage opened",
|
|
"path", dataSourceName,
|
|
"dev_mode", devMode,
|
|
"write_queue_capacity", writeQueueCapacity,
|
|
"max_open_connections", 8,
|
|
)
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func sqliteDSN(dataSourceName string) string {
|
|
if dataSourceName == ":memory:" {
|
|
// Each Store needs a private shared-cache database: shared cache keeps
|
|
// that Store's pooled connections on one database, while the unique name
|
|
// prevents independent in-memory stores from leaking into each other.
|
|
dataSourceName = fmt.Sprintf(
|
|
"file:chess-memory-%d?mode=memory&cache=shared",
|
|
memoryStoreCounter.Add(1),
|
|
)
|
|
}
|
|
separator := "?"
|
|
if strings.Contains(dataSourceName, "?") {
|
|
separator = "&"
|
|
}
|
|
return dataSourceName + separator +
|
|
"_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL"
|
|
}
|
|
|
|
// 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 a transactional write operation
|
|
func (s *Store) executeWrite(req writeRequest) error {
|
|
started := time.Now()
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
s.writeFailed.Store(true)
|
|
s.healthStatus.Store(false)
|
|
slog.Error("storage degraded: failed to begin transaction",
|
|
"operation", req.operation, "game_id", req.gameID, "error", err)
|
|
return err
|
|
}
|
|
|
|
if err := req.run(tx); err != nil {
|
|
rollbackErr := tx.Rollback()
|
|
s.writeFailed.Store(true)
|
|
s.healthStatus.Store(false)
|
|
slog.Error("storage degraded: write operation failed",
|
|
"operation", req.operation,
|
|
"game_id", req.gameID,
|
|
"error", err,
|
|
"rollback_error", rollbackErr,
|
|
)
|
|
return err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
s.writeFailed.Store(true)
|
|
s.healthStatus.Store(false)
|
|
slog.Error("storage degraded: failed to commit",
|
|
"operation", req.operation, "game_id", req.gameID, "error", err)
|
|
return err
|
|
}
|
|
|
|
slog.Debug("storage write committed",
|
|
"operation", req.operation,
|
|
"game_id", req.gameID,
|
|
"duration", time.Since(started),
|
|
"queue_depth", len(s.writeChan),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) enqueue(operation, gameID string, fn func(*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
|
|
}
|
|
}
|
|
|
|
// 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 gracefully closes the database connection
|
|
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 creates the database schema
|
|
func (s *Store) InitDB() error {
|
|
started := time.Now()
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var currentVersion int
|
|
if err := tx.QueryRow("PRAGMA user_version").Scan(¤tVersion); err != nil {
|
|
return fmt.Errorf("failed to read schema version: %w", err)
|
|
}
|
|
if currentVersion > schemaVersion {
|
|
return fmt.Errorf(
|
|
"database schema version %d is newer than supported version %d",
|
|
currentVersion,
|
|
schemaVersion,
|
|
)
|
|
}
|
|
|
|
if _, err := tx.Exec(Schema); err != nil {
|
|
return fmt.Errorf("failed to create schema: %w", err)
|
|
}
|
|
|
|
columns := []struct {
|
|
name string
|
|
definition string
|
|
}{
|
|
{"result", "TEXT CHECK(result IS NULL OR result IN ('white_wins', 'black_wins', 'draw', 'stalemate'))"},
|
|
{"end_time_utc", "DATETIME"},
|
|
{"white_claimed_by", "TEXT"},
|
|
{"black_claimed_by", "TEXT"},
|
|
}
|
|
for _, column := range columns {
|
|
if err := ensureColumn(tx, "games", column.name, column.definition); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// These indexes duplicate UNIQUE constraints or are superseded by targeted
|
|
// partial/composite indexes. Drop them during upgrades as well as omitting
|
|
// them from new databases.
|
|
for _, name := range []string{
|
|
"idx_users_username",
|
|
"idx_users_email",
|
|
"idx_users_account_type",
|
|
"idx_users_expires_at",
|
|
"idx_sessions_user_id",
|
|
"idx_moves_game_id",
|
|
"idx_games_finished_end_time",
|
|
} {
|
|
if _, err := tx.Exec("DROP INDEX IF EXISTS " + name); err != nil {
|
|
return fmt.Errorf("failed to remove redundant index %s: %w", name, err)
|
|
}
|
|
}
|
|
|
|
if _, err := tx.Exec(Indexes); err != nil {
|
|
return fmt.Errorf("failed to create indexes: %w", err)
|
|
}
|
|
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", 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.Debug("storage schema ready", "version", schemaVersion, "duration", time.Since(started))
|
|
return nil
|
|
}
|
|
|
|
func ensureColumn(tx *sql.Tx, table, column, definition string) error {
|
|
rows, err := tx.Query("PRAGMA table_info(" + table + ")")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
|
|
}
|
|
|
|
found := false
|
|
for rows.Next() {
|
|
var cid int
|
|
var name, columnType string
|
|
var notNull, primaryKey int
|
|
var defaultValue any
|
|
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil {
|
|
rows.Close()
|
|
return fmt.Errorf("failed to inspect %s column: %w", table, err)
|
|
}
|
|
if name == column {
|
|
found = true
|
|
}
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return fmt.Errorf("failed to close %s schema rows: %w", table, err)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("failed to inspect %s schema: %w", table, err)
|
|
}
|
|
if found {
|
|
return nil
|
|
}
|
|
|
|
if _, err := tx.Exec("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition); err != nil {
|
|
return fmt.Errorf("failed to add %s.%s: %w", table, column, err)
|
|
}
|
|
slog.Debug("storage schema column added", "table", table, "column", column)
|
|
return nil
|
|
}
|
|
|
|
// DeleteDB removes the database file
|
|
func (s *Store) DeleteDB() error {
|
|
// Close connection first
|
|
if err := s.Close(); err != nil {
|
|
return fmt.Errorf("failed to close database: %w", err)
|
|
}
|
|
|
|
// ☣ DESTRUCTIVE: Removes database file
|
|
if err := os.Remove(s.path); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to delete database file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|