v0.3.0 storage with sqlite3 and pid management added
This commit is contained in:
@ -4,6 +4,7 @@ package http
|
||||
import (
|
||||
"chess/internal/core"
|
||||
"chess/internal/processor"
|
||||
"chess/internal/service"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@ -19,15 +20,16 @@ const rateLimitRate = 10 // req/sec
|
||||
|
||||
type HTTPHandler struct {
|
||||
proc *processor.Processor
|
||||
svc *service.Service
|
||||
}
|
||||
|
||||
func NewHTTPHandler(proc *processor.Processor) *HTTPHandler {
|
||||
return &HTTPHandler{proc: proc}
|
||||
func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandler {
|
||||
return &HTTPHandler{proc: proc, svc: svc}
|
||||
}
|
||||
|
||||
func NewFiberApp(proc *processor.Processor, devMode bool) *fiber.App {
|
||||
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode bool) *fiber.App {
|
||||
// Create handler
|
||||
h := NewHTTPHandler(proc)
|
||||
h := NewHTTPHandler(proc, svc)
|
||||
|
||||
// Initialize Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
@ -146,11 +148,12 @@ func customErrorHandler(c *fiber.Ctx, err error) error {
|
||||
return c.Status(code).JSON(response)
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
// Health check endpoint with storage status
|
||||
func (h *HTTPHandler) Health(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "healthy",
|
||||
"time": time.Now().Unix(),
|
||||
"status": "healthy",
|
||||
"time": time.Now().Unix(),
|
||||
"storage": h.svc.GetStorageHealth(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -4,24 +4,27 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chess/internal/core"
|
||||
"chess/internal/game"
|
||||
"chess/internal/storage"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Service is a pure state manager for chess games
|
||||
// It has NO knowledge of chess rules or engine interactions
|
||||
// Service is a pure state manager for chess games with optional persistence
|
||||
type Service struct {
|
||||
games map[string]*game.Game
|
||||
mu sync.RWMutex
|
||||
store *storage.Store // nil if persistence disabled
|
||||
}
|
||||
|
||||
// New creates a new service instance
|
||||
func New() (*Service, error) {
|
||||
// New creates a new service instance with optional storage
|
||||
func New(store *storage.Store) (*Service, error) {
|
||||
return &Service{
|
||||
games: make(map[string]*game.Game),
|
||||
store: store,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -39,6 +42,25 @@ func (s *Service) CreateGame(id string, whiteConfig, blackConfig core.PlayerConf
|
||||
blackPlayer := core.NewPlayer(blackConfig, core.ColorBlack)
|
||||
|
||||
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
|
||||
|
||||
// Persist if storage enabled
|
||||
if s.store != nil {
|
||||
record := storage.GameRecord{
|
||||
GameID: id,
|
||||
InitialFEN: initialFEN,
|
||||
WhitePlayerID: whitePlayer.ID,
|
||||
WhiteType: int(whitePlayer.Type),
|
||||
WhiteLevel: whitePlayer.Level,
|
||||
WhiteSearchTime: whitePlayer.SearchTime,
|
||||
BlackPlayerID: blackPlayer.ID,
|
||||
BlackType: int(blackPlayer.Type),
|
||||
BlackLevel: blackPlayer.Level,
|
||||
BlackSearchTime: blackPlayer.SearchTime,
|
||||
StartTimeUTC: time.Now().UTC(),
|
||||
}
|
||||
s.store.RecordNewGame(record)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -76,11 +98,19 @@ func (s *Service) GetGame(gameID string) (*game.Game, error) {
|
||||
|
||||
// GenerateGameID creates a new unique game ID
|
||||
func (s *Service) GenerateGameID() string {
|
||||
return uuid.New().String()
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// Ensure UUID uniqueness (handle potential conflicts)
|
||||
for {
|
||||
id := uuid.New().String()
|
||||
if _, exists := s.games[id]; !exists {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyMove adds a validated move to the game history
|
||||
// The processor has already validated this move and calculated the new FEN
|
||||
func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@ -97,6 +127,20 @@ func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
|
||||
// Add the new position to game history
|
||||
g.AddSnapshot(newFEN, moveUCI, nextTurn)
|
||||
|
||||
// Persist if storage enabled
|
||||
if s.store != nil {
|
||||
moveNumber := len(g.Moves())
|
||||
record := storage.MoveRecord{
|
||||
GameID: gameID,
|
||||
MoveNumber: moveNumber,
|
||||
MoveUCI: moveUCI,
|
||||
FENAfterMove: newFEN,
|
||||
PlayerColor: currentTurn.String(),
|
||||
MoveTimeUTC: time.Now().UTC(),
|
||||
}
|
||||
s.store.RecordMove(record)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -114,8 +158,7 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLastMoveResult stores metadata about the last move (score, depth, etc)
|
||||
// Used by processor to track computer move evaluations
|
||||
// SetLastMoveResult stores metadata about the last move
|
||||
func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@ -139,7 +182,19 @@ func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
return g.UndoMoves(count)
|
||||
originalMoveCount := len(g.Moves())
|
||||
|
||||
if err := g.UndoMoves(count); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete undone moves from storage if enabled
|
||||
if s.store != nil {
|
||||
remainingMoves := originalMoveCount - count
|
||||
s.store.DeleteUndoneMoves(gameID, remainingMoves)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteGame removes a game from memory
|
||||
@ -155,12 +210,29 @@ func (s *Service) DeleteGame(gameID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close cleans up resources (currently a no-op as no engine to close)
|
||||
// GetStorageHealth returns the storage component status
|
||||
func (s *Service) GetStorageHealth() string {
|
||||
if s.store == nil {
|
||||
return "disabled"
|
||||
}
|
||||
if s.store.IsHealthy() {
|
||||
return "ok"
|
||||
}
|
||||
return "degraded"
|
||||
}
|
||||
|
||||
// Close cleans up resources
|
||||
func (s *Service) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Clear all games
|
||||
s.games = make(map[string]*game.Game)
|
||||
|
||||
// Close storage if enabled
|
||||
if s.store != nil {
|
||||
return s.store.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
63
internal/storage/schema.go
Normal file
63
internal/storage/schema.go
Normal file
@ -0,0 +1,63 @@
|
||||
// FILE: internal/storage/schema.go
|
||||
package storage
|
||||
|
||||
import "time"
|
||||
|
||||
// GameRecord represents a row in the games table
|
||||
type GameRecord struct {
|
||||
GameID string `db:"game_id"`
|
||||
InitialFEN string `db:"initial_fen"`
|
||||
WhitePlayerID string `db:"white_player_id"`
|
||||
WhiteType int `db:"white_type"`
|
||||
WhiteLevel int `db:"white_level"`
|
||||
WhiteSearchTime int `db:"white_search_time"`
|
||||
BlackPlayerID string `db:"black_player_id"`
|
||||
BlackType int `db:"black_type"`
|
||||
BlackLevel int `db:"black_level"`
|
||||
BlackSearchTime int `db:"black_search_time"`
|
||||
StartTimeUTC time.Time `db:"start_time_utc"`
|
||||
}
|
||||
|
||||
// MoveRecord represents a row in the moves table
|
||||
type MoveRecord struct {
|
||||
MoveID int64 `db:"move_id"`
|
||||
GameID string `db:"game_id"`
|
||||
MoveNumber int `db:"move_number"`
|
||||
MoveUCI string `db:"move_uci"`
|
||||
FENAfterMove string `db:"fen_after_move"`
|
||||
PlayerColor string `db:"player_color"` // "w" or "b"
|
||||
MoveTimeUTC time.Time `db:"move_time_utc"`
|
||||
}
|
||||
|
||||
// Schema defines the SQLite database structure
|
||||
const Schema = `
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
game_id TEXT PRIMARY KEY,
|
||||
initial_fen TEXT NOT NULL,
|
||||
white_player_id TEXT NOT NULL,
|
||||
white_type INTEGER NOT NULL,
|
||||
white_level INTEGER NOT NULL DEFAULT 0,
|
||||
white_search_time INTEGER NOT NULL DEFAULT 1000,
|
||||
black_player_id TEXT NOT NULL,
|
||||
black_type INTEGER NOT NULL,
|
||||
black_level INTEGER NOT NULL DEFAULT 0,
|
||||
black_search_time INTEGER NOT NULL DEFAULT 1000,
|
||||
start_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS moves (
|
||||
move_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_id TEXT NOT NULL,
|
||||
move_number INTEGER NOT NULL,
|
||||
move_uci TEXT NOT NULL,
|
||||
fen_after_move TEXT NOT NULL,
|
||||
player_color TEXT NOT NULL CHECK(player_color IN ('w', 'b')),
|
||||
move_time_utc DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (game_id) REFERENCES games(game_id) ON DELETE CASCADE,
|
||||
UNIQUE(game_id, move_number)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_moves_game_id ON moves(game_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_white_player ON games(white_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_black_player ON games(black_player_id);
|
||||
`
|
||||
316
internal/storage/storage.go
Normal file
316
internal/storage/storage.go
Normal file
@ -0,0 +1,316 @@
|
||||
// FILE: internal/storage/storage.go
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Store handles SQLite database operations with async writes
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
path string
|
||||
writeChan chan func(*sql.Tx) error
|
||||
healthStatus atomic.Bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewStore creates a new storage instance with async writer
|
||||
func NewStore(dataSourceName string, devMode bool) (*Store, error) {
|
||||
db, err := sql.Open("sqlite3", dataSourceName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// Enable WAL mode in development for better concurrency
|
||||
if devMode {
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to enable WAL mode: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Enable foreign keys
|
||||
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to enable foreign keys: %w", err)
|
||||
}
|
||||
|
||||
// Configure connection pool
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
s := &Store{
|
||||
db: db,
|
||||
path: dataSourceName,
|
||||
writeChan: make(chan func(*sql.Tx) error, 1000), // Buffered for async writes
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Initialize health as true
|
||||
s.healthStatus.Store(true)
|
||||
|
||||
// Start async writer
|
||||
s.wg.Add(1)
|
||||
go s.writerLoop()
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// writerLoop processes async write operations
|
||||
func (s *Store) writerLoop() {
|
||||
defer s.wg.Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
// Drain remaining writes with timeout
|
||||
deadline := time.After(2 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case fn := <-s.writeChan:
|
||||
if s.healthStatus.Load() {
|
||||
s.executeWrite(fn)
|
||||
}
|
||||
case <-deadline:
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
case fn := <-s.writeChan:
|
||||
// Skip if already degraded
|
||||
if !s.healthStatus.Load() {
|
||||
continue
|
||||
}
|
||||
s.executeWrite(fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeWrite runs a transactional write operation
|
||||
func (s *Store) executeWrite(fn func(*sql.Tx) error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
log.Printf("Storage degraded: failed to begin transaction: %v", err)
|
||||
s.healthStatus.Store(false)
|
||||
return
|
||||
}
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
tx.Rollback()
|
||||
log.Printf("Storage degraded: write operation failed: %v", err)
|
||||
s.healthStatus.Store(false)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("Storage degraded: failed to commit: %v", err)
|
||||
s.healthStatus.Store(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// RecordNewGame asynchronously records a new game
|
||||
func (s *Store) RecordNewGame(record GameRecord) error {
|
||||
if !s.healthStatus.Load() {
|
||||
return nil // Silently drop if degraded
|
||||
}
|
||||
|
||||
select {
|
||||
case s.writeChan <- func(tx *sql.Tx) error {
|
||||
query := `INSERT INTO games (
|
||||
game_id, initial_fen,
|
||||
white_player_id, white_type, white_level, white_search_time,
|
||||
black_player_id, black_type, black_level, black_search_time,
|
||||
start_time_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err := tx.Exec(query,
|
||||
record.GameID, record.InitialFEN,
|
||||
record.WhitePlayerID, record.WhiteType, record.WhiteLevel, record.WhiteSearchTime,
|
||||
record.BlackPlayerID, record.BlackType, record.BlackLevel, record.BlackSearchTime,
|
||||
record.StartTimeUTC,
|
||||
)
|
||||
return err
|
||||
}:
|
||||
return nil
|
||||
default:
|
||||
// Channel full, drop write
|
||||
log.Printf("Storage write queue full, dropping game record")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RecordMove asynchronously records a move
|
||||
func (s *Store) RecordMove(record MoveRecord) error {
|
||||
if !s.healthStatus.Load() {
|
||||
return nil // Silently drop if degraded
|
||||
}
|
||||
|
||||
select {
|
||||
case s.writeChan <- func(tx *sql.Tx) error {
|
||||
query := `INSERT INTO moves (
|
||||
game_id, move_number, move_uci, fen_after_move, player_color, move_time_utc
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err := tx.Exec(query,
|
||||
record.GameID, record.MoveNumber, record.MoveUCI,
|
||||
record.FENAfterMove, record.PlayerColor, record.MoveTimeUTC,
|
||||
)
|
||||
return err
|
||||
}:
|
||||
return nil
|
||||
default:
|
||||
// Channel full, drop write
|
||||
log.Printf("Storage write queue full, dropping move record")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteUndoneMoves asynchronously deletes moves after undo
|
||||
func (s *Store) DeleteUndoneMoves(gameID string, afterMoveNumber int) error {
|
||||
if !s.healthStatus.Load() {
|
||||
return nil // Silently drop if degraded
|
||||
}
|
||||
|
||||
select {
|
||||
case s.writeChan <- func(tx *sql.Tx) error {
|
||||
query := `DELETE FROM moves WHERE game_id = ? AND move_number > ?`
|
||||
_, err := tx.Exec(query, gameID, afterMoveNumber)
|
||||
return err
|
||||
}:
|
||||
return nil
|
||||
default:
|
||||
// Channel full, drop write
|
||||
log.Printf("Storage write queue full, dropping undo operation")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// IsHealthy returns the current health status
|
||||
func (s *Store) IsHealthy() bool {
|
||||
return s.healthStatus.Load()
|
||||
}
|
||||
|
||||
// Close gracefully closes the database connection
|
||||
func (s *Store) Close() error {
|
||||
// Signal writer to stop
|
||||
s.cancel()
|
||||
|
||||
// Wait for writer with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Writer finished cleanly
|
||||
case <-time.After(2 * time.Second):
|
||||
log.Printf("Warning: storage writer shutdown timeout, some writes may be lost")
|
||||
}
|
||||
|
||||
if s.db != nil {
|
||||
return s.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitDB creates the database schema
|
||||
func (s *Store) InitDB() error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(Schema); err != nil {
|
||||
return fmt.Errorf("failed to create schema: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// QueryGames retrieves games with optional filtering
|
||||
func (s *Store) QueryGames(gameID, playerID string) ([]GameRecord, error) {
|
||||
query := `SELECT
|
||||
game_id, initial_fen,
|
||||
white_player_id, white_type, white_level, white_search_time,
|
||||
black_player_id, black_type, black_level, black_search_time,
|
||||
start_time_utc
|
||||
FROM games WHERE 1=1`
|
||||
|
||||
var args []interface{}
|
||||
|
||||
// Handle gameID filtering
|
||||
if gameID != "" && gameID != "*" {
|
||||
query += " AND game_id = ?"
|
||||
args = append(args, gameID)
|
||||
}
|
||||
|
||||
// Handle playerID filtering
|
||||
if playerID != "" && playerID != "*" {
|
||||
query += " AND (white_player_id = ? OR black_player_id = ?)"
|
||||
args = append(args, playerID, playerID)
|
||||
}
|
||||
|
||||
query += " ORDER BY start_time_utc DESC"
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var games []GameRecord
|
||||
for rows.Next() {
|
||||
var g GameRecord
|
||||
err := rows.Scan(
|
||||
&g.GameID, &g.InitialFEN,
|
||||
&g.WhitePlayerID, &g.WhiteType, &g.WhiteLevel, &g.WhiteSearchTime,
|
||||
&g.BlackPlayerID, &g.BlackType, &g.BlackLevel, &g.BlackSearchTime,
|
||||
&g.StartTimeUTC,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan failed: %w", err)
|
||||
}
|
||||
games = append(games, g)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration failed: %w", err)
|
||||
}
|
||||
|
||||
return games, nil
|
||||
}
|
||||
Reference in New Issue
Block a user