feat(server): initial implementation

This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:

1. Base infrastructure
   - cmd/poc-server HTTP+WebSocket server, /health, /stats
   - internal/transport wire types (Envelope + all message types)
   - internal/catalog, internal/realtime, internal/stats
   - internal/storage/postgres with migrations 001-004
   - .env, .env.example, docker-compose, Dockerfile, scripts/

2. Swagger documentation
   - go get github.com/swaggo/http-swagger
   - swag init produces docs/docs.go
   - /swagger/index.html UI, /swagger/doc.json spec
   - annotations on health/stats/ws and catalog handlers

3. Races API
   - internal/races/{store,service,keyset,types}.go
   - migration 005_races.sql: finished_races, race_plans, race_queue
   - GET /api/races with keyset pagination, status filter
   - GET /api/races/upcoming
   - POST /api/races/queue/join, GET, DELETE
   - POST/GET /api/races/plans, DELETE /{id}
   - lobby.RaceMeta simplification (no host_id/host_name)

4. Races seeder
   - internal/races/seed with deterministic generator
   - --seed-races / --reset CLI flags in main.go
   - 30 finished + 5 live + 5 plans + 4 queue entries

5. Drivers and clans
   - internal/drivers, internal/clans (CRUD, validation)
   - migration 008_drivers_clans.sql
   - /api/clans and /api/drivers endpoints
   - 3-letter uppercase nickname/tag validation
   - lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag

6. Podium
   - internal/transport.RacePodiumEntry
   - lobby.SetDriverProfile for in-memory metadata sync
   - migration 007_podium.sql (podium JSONB column)
   - seeder populates top-3 per finished race

7. Live races persistence
   - migration 009_live_persistence.sql: live_races,
     live_race_drivers, lobby_drivers
   - internal/races/live_store.go: LiveStore with write-side
     mirror for lobby.Service mutations
   - Service.collectLive and Upcoming read from Postgres
   - main.go RestoreFromDB rehydrates lobby on startup

8. Unify live + finished into one races table
   - migration 010_unify_races.sql: rename finished_races to
     races, expand status CHECK, merge live rows
   - PgStore now hosts both write paths; LiveStore is a
     thin facade implementing lobby.Persistence
   - seeder resetAll drops only finished/cancelled rows and
     race_plans / race_queue / lobby_drivers

Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
This commit is contained in:
x0gp
2026-06-22 22:01:09 +04:00
commit 978d36c505
71 changed files with 23500 additions and 0 deletions
+714
View File
@@ -0,0 +1,714 @@
package races
import (
"context"
"fmt"
"sort"
"sync"
"time"
"github.com/x0gp/server/internal/lobby"
)
// Service composes lobby.Service (live) with PgStore (finished/plans/queue)
// and exposes the read/join methods used by the HTTP handlers and the
// scheduler.
type Service struct {
pg *PgStore
lobby *lobby.Service
live *LiveStore // optional: when set, live reads go through DB
now func() time.Time
maxIDs int
}
// NewService wires a Service. now is overridable for tests.
func NewService(pg *PgStore, lb *lobby.Service) *Service {
return &Service{
pg: pg,
lobby: lb,
now: time.Now,
maxIDs: 3,
}
}
// SetLiveStore installs the LiveStore (DB-backed live races). When set,
// the read side of the races list pulls from Postgres instead of the
// in-memory lobby. Writes still go through lobby.Service.
func (s *Service) SetLiveStore(live *LiveStore) {
s.live = live
}
// RestoreFromDB rehydrates the in-memory lobby from the live_races and
// lobby_drivers tables. Called at startup so that active races and
// driver presence survive a server restart. If LiveStore is not
// configured this is a no-op.
func (s *Service) RestoreFromDB(ctx context.Context) (int, int, error) {
if s.live == nil {
return 0, 0, nil
}
races, err := s.live.ListAllRaces(ctx)
if err != nil {
return 0, 0, fmt.Errorf("restore races: %w", err)
}
drivers, err := s.live.ListAllDrivers(ctx)
if err != nil {
return 0, 0, fmt.Errorf("restore drivers: %w", err)
}
// Restore drivers first so the race restore can attach them.
for _, d := range drivers {
// We bypass the normal AddDriver because we don't want to
// re-trigger a persistence write for already-persisted rows.
// Use the internal helper if available; otherwise go through
// AddDriver+SetDriverProfile and accept a no-op mirror.
_, _ = s.lobby.AddDriver(d.ID, d.Name, "")
s.lobby.SetDriverProfile(d.ID, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag)
}
for _, r := range races {
// We bypass CreateRace to avoid duplicating persistence writes
// for races that are already in the DB. AddRace (a new helper
// on lobby.Service) inserts the race without mirroring.
s.lobby.AddRace(r)
for _, did := range r.DriverIDs {
_ = s.lobby.AddDriverToRace(did, r.ID)
}
}
return len(races), len(drivers), nil
}
// SetMaxUpcoming overrides the default "next N" queue size (default 3).
func (s *Service) SetMaxUpcoming(n int) {
if n > 0 && n <= 16 {
s.maxIDs = n
}
}
// ---------------------------------------------------------------------------
// List (keyset, mixed live + finished)
// ---------------------------------------------------------------------------
// ListFilter is the parsed query for ListRaces.
type ListFilter struct {
Statuses []StatusFilter // resolved filter; empty = all
TrackID string
Cursor Cursor
Limit int
}
// RaceItem is one row in the paginated result.
type RaceItem struct {
Source string // "live" | "finished"
Meta lobby.RaceMeta // always populated
// Podium is populated for finished races only.
Podium []PodiumEntry
}
// ListResult is a keyset page.
type ListResult struct {
Items []RaceItem
NextCursor string // empty when no more pages
}
// ListRaces returns one keyset page of races matching the filter.
//
// Pagination strategy: live races (in-memory) are stable per snapshot but
// don't have a DB-side index. To honour the "keyset" contract we page
// live and finished sources independently and merge by sort key:
//
// * Live races use started_ms if set, otherwise created_ms.
// * Finished races use finished_ms.
//
// Pages are pulled live-first when any "active" status is in the filter,
// or finished-first when status=finished. The cursor encodes
// ("live"|"finished", sort_ms, race_id) so successive pages stay in order.
func (s *Service) ListRaces(ctx context.Context, f ListFilter) (ListResult, error) {
limit := f.Limit
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
wantFinished := false
wantLive := false
for _, st := range f.Statuses {
if st == StatusAll {
wantLive = true
wantFinished = true
break
}
if st.matchesLive("finished") || st == StatusFinished {
wantFinished = true
}
if st.matchesLive("lobby") || st == StatusLobby ||
st == StatusRacing {
wantLive = true
}
}
// Parse cursor split (source/ms/id) if present.
curSource, curMs, curID := "live", int64(0), ""
if !f.Cursor.IsZero() {
curSource, curMs, curID = splitCursor(f.Cursor)
}
// Collect candidates from each source up to (limit+1) entries.
out := make([]RaceItem, 0, limit+1)
if wantLive {
live, err := s.collectLive(ctx, f.Statuses, f.TrackID, curSource, curMs, curID, limit+1)
if err != nil {
return ListResult{}, err
}
out = append(out, live...)
}
if wantFinished {
finished, err := s.collectFinished(ctx, f.Statuses, f.TrackID, curSource, curMs, curID, limit+1)
if err != nil {
return ListResult{}, err
}
out = append(out, finished...)
}
// Sort by (sort_ms DESC, id ASC), stable.
sort.SliceStable(out, func(i, j int) bool {
mi := sortMs(out[i].Meta)
mj := sortMs(out[j].Meta)
if mi != mj {
return mi > mj
}
return out[i].Meta.ID < out[j].Meta.ID
})
hasMore := false
if len(out) > limit {
hasMore = true
out = out[:limit]
}
res := ListResult{Items: out}
if hasMore && len(out) > 0 {
last := out[len(out)-1]
res.NextCursor = Cursor{
Ms: sortMs(last.Meta),
ID: last.Meta.ID,
}.Encode()
}
return res, nil
}
func sortMs(m lobby.RaceMeta) int64 {
if m.Status == lobby.RaceStatusFinished {
// Finished meta from PgStore has no FinishedMs field; StartedMs is the
// best proxy for the original finished time within the lobby shape.
if m.StartedMs > 0 {
return m.StartedMs
}
return m.CreatedMs
}
if m.StartedMs > 0 {
return m.StartedMs
}
return m.CreatedMs
}
// collectLive fetches one page of live races. If s.live (LiveStore) is
// set, the data comes from Postgres; otherwise it falls back to the
// in-memory lobby.
func (s *Service) collectLive(ctx context.Context, filters []StatusFilter, trackID, curSource string, curMs int64, curID string, limit int) ([]RaceItem, error) {
if s.live != nil && curSource == "live" {
return s.collectLiveDB(ctx, filters, trackID, curMs, curID, limit)
}
// Fallback: in-memory lobby. The in-memory branch still applies the
// cursor manually because the legacy splitCursor format encodes the
// source in the id suffix.
all := s.lobby.ListRaces()
out := make([]RaceItem, 0, limit)
for _, r := range all {
if !statusMatches(filters, string(r.Status)) {
continue
}
if trackID != "" && r.TrackID != trackID {
continue
}
sortKey := sortMs(r)
if curSource == "live" {
if sortKey < curMs || (sortKey == curMs && r.ID >= curID) {
continue
}
}
out = append(out, RaceItem{Source: "live", Meta: r})
if len(out) >= limit {
break
}
}
return out, nil
}
func (s *Service) collectLiveDB(ctx context.Context, filters []StatusFilter, trackID string, curMs int64, curID string, limit int) ([]RaceItem, error) {
statuses := filterToStatusStrings(filters)
cur := Cursor{Ms: curMs, ID: curID}
rows, err := s.live.ListLivePaged(ctx, statuses, trackID, cur, limit)
if err != nil {
return nil, fmt.Errorf("list live: %w", err)
}
out := make([]RaceItem, 0, len(rows))
for _, r := range rows {
// Skip anything that doesn't match the in-memory status filter
// (DB already filtered; this is a safety belt).
if !statusMatches(filters, string(r.Status)) {
continue
}
out = append(out, RaceItem{Source: "live", Meta: r})
}
return out, nil
}
// filterToStatusStrings returns the list of status values that the DB
// query should match. StatusAll expands to the three live statuses
// (lobby, countdown, racing). The caller adds the finished source
// separately.
func filterToStatusStrings(filters []StatusFilter) []string {
if len(filters) == 0 {
return []string{string(lobby.RaceStatusLobby), string(lobby.RaceStatusCountdown), string(lobby.RaceStatusRacing)}
}
seen := map[string]bool{}
for _, f := range filters {
switch f {
case StatusAll:
return []string{string(lobby.RaceStatusLobby), string(lobby.RaceStatusCountdown), string(lobby.RaceStatusRacing)}
case StatusRacing:
seen["racing"] = true
case StatusLobby:
seen["lobby"] = true
case StatusFinished:
// finished is served by collectFinished; skip here.
}
}
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
return out
}
func (s *Service) collectFinished(ctx context.Context, filters []StatusFilter, trackID, curSource string, curMs int64, curID string, limit int) ([]RaceItem, error) {
if !statusWantsFinished(filters) {
return nil, nil
}
cur := Cursor{}
if curSource == "finished" {
cur = Cursor{Ms: curMs, ID: curID}
}
rows, err := s.pg.ListFinished(ctx, trackID, cur, limit)
if err != nil {
return nil, err
}
out := make([]RaceItem, 0, len(rows))
for _, f := range rows {
out = append(out, RaceItem{Source: "finished", Meta: f.ToLobbyMeta(), Podium: f.Podium})
}
return out, nil
}
func statusMatches(filters []StatusFilter, liveStatus string) bool {
if len(filters) == 0 {
return true
}
for _, f := range filters {
if f == StatusAll {
return true
}
if f.matchesLive(liveStatus) {
return true
}
}
return false
}
// statusWantsFinished is true if the filter list allows finished races
// through. Used to gate the Postgres query in collectFinished.
func statusWantsFinished(filters []StatusFilter) bool {
if len(filters) == 0 {
return true
}
for _, f := range filters {
if f == StatusAll || f == StatusFinished {
return true
}
}
return false
}
// splitCursor is a no-op shim; we encode source in the id field for v1
// simplicity (suffix ":src"). Returns ("live"|"finished", ms, id).
func splitCursor(c Cursor) (string, int64, string) {
id := c.ID
src := "live"
// Convention: id may end with ":finished" or ":live". If absent, treat as live.
for _, suf := range []string{":finished", ":live"} {
if len(id) > len(suf) && id[len(id)-len(suf):] == suf {
id = id[:len(id)-len(suf)]
src = suf[1:]
break
}
}
return src, c.Ms, id
}
// ---------------------------------------------------------------------------
// Upcoming (next N) and queue join
// ---------------------------------------------------------------------------
// UpcomingItem is one row in the upcoming list.
type UpcomingItem struct {
Meta lobby.RaceMeta
QueueLen int
PlanID string
StartAtMs int64 // best-effort: StartedMs for live races, otherwise CreatedMs+offset
}
// Upcoming returns the next N open (lobby|countdown) races, soonest first.
func (s *Service) Upcoming(ctx context.Context, limit int) ([]UpcomingItem, error) {
if limit <= 0 {
limit = s.maxIDs
}
if limit > 16 {
limit = 16
}
// When LiveStore is configured, read upcoming from Postgres.
if s.live != nil {
return s.upcomingDB(ctx, limit)
}
all := s.lobby.ListRaces()
out := make([]UpcomingItem, 0, limit)
for _, r := range all {
if r.Status != lobby.RaceStatusLobby && r.Status != lobby.RaceStatusCountdown {
continue
}
n, err := s.pg.CountQueueByRace(ctx, r.ID)
if err != nil {
return nil, err
}
startAt := r.StartedMs
if startAt == 0 {
startAt = r.CreatedMs
}
out = append(out, UpcomingItem{
Meta: r,
QueueLen: n,
StartAtMs: startAt,
})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].StartAtMs != out[j].StartAtMs {
return out[i].StartAtMs < out[j].StartAtMs
}
return out[i].Meta.ID < out[j].Meta.ID
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (s *Service) upcomingDB(ctx context.Context, limit int) ([]UpcomingItem, error) {
rows, err := s.live.ListUpcoming(ctx, limit)
if err != nil {
return nil, fmt.Errorf("list upcoming: %w", err)
}
out := make([]UpcomingItem, 0, len(rows))
for _, r := range rows {
n, err := s.pg.CountQueueByRace(ctx, r.ID)
if err != nil {
return nil, err
}
startAt := r.StartedMs
if startAt == 0 {
startAt = r.CreatedMs
}
out = append(out, UpcomingItem{
Meta: r,
QueueLen: n,
StartAtMs: startAt,
})
}
return out, nil
}
// JoinUpcomingResult is what JoinUpcoming returns.
type JoinUpcomingResult struct {
Joined []QueueEntry
Skipped []string // race ids that were already at capacity
}
// JoinUpcoming puts the driver into the queue for each of the next N
// upcoming races. Idempotent per (driver, race).
func (s *Service) JoinUpcoming(ctx context.Context, driverID string, limit int) (JoinUpcomingResult, error) {
if driverID == "" {
return JoinUpcomingResult{}, fmt.Errorf("%w: driver_id required", ErrInvalidInput)
}
up, err := s.Upcoming(ctx, limit)
if err != nil {
return JoinUpcomingResult{}, err
}
res := JoinUpcomingResult{Joined: make([]QueueEntry, 0, len(up))}
for _, u := range up {
// Skip races that are full; surfaced in Skipped for the caller.
if len(u.Meta.DriverIDs) >= u.Meta.MaxCars {
res.Skipped = append(res.Skipped, u.Meta.ID)
continue
}
// Best-effort auto-attach: if the race is still lobby and the driver
// can fit, AddDriverToRace moves them in. The queue entry remains
// for diagnostics.
if u.Meta.Status == lobby.RaceStatusLobby && len(u.Meta.DriverIDs) < u.Meta.MaxCars {
_ = s.lobby.AddDriverToRace(driverID, u.Meta.ID)
}
q, err := s.pg.Enqueue(ctx, driverID, u.Meta.ID, u.PlanID)
if err != nil {
return res, err
}
res.Joined = append(res.Joined, q)
}
return res, nil
}
// LeaveQueue removes a single (driver, race) entry.
func (s *Service) LeaveQueue(ctx context.Context, driverID, raceID string) error {
return s.pg.Dequeue(ctx, driverID, raceID)
}
// ListQueue returns the driver's queue entries.
func (s *Service) ListQueue(ctx context.Context, driverID string) ([]QueueEntry, error) {
return s.pg.ListQueueByDriver(ctx, driverID)
}
// ---------------------------------------------------------------------------
// Finished-race snapshot hook
// ---------------------------------------------------------------------------
// SnapshotFinished is the input to the "persist on finish" hook. Race is
// a copy of the in-memory meta. Result is filled by the engine.
type SnapshotFinished struct {
Meta lobby.RaceMeta
FinishedMs int64
TotalLaps int
WinnerDriverID string
WinnerName string
BestLapMs int64
}
// PersistFinished upserts the finished race into Postgres.
func (s *Service) PersistFinished(ctx context.Context, sf SnapshotFinished) error {
startedMs := sf.Meta.StartedMs
finishedMs := sf.FinishedMs
if finishedMs == 0 {
finishedMs = s.now().UnixMilli()
}
r := FinishedRace{
ID: sf.Meta.ID,
Name: sf.Meta.Name,
TrackID: sf.Meta.TrackID,
MaxCars: sf.Meta.MaxCars,
Laps: sf.Meta.Laps,
TimeLimitS: sf.Meta.TimeLimitS,
DriverIDs: append([]string(nil), sf.Meta.DriverIDs...),
Status: "finished",
CreatedMs: sf.Meta.CreatedMs,
StartedMs: startedMs,
FinishedMs: finishedMs,
DurationMs: finishedMs - startedMs,
TotalLaps: sf.TotalLaps,
TotalDrivers: len(sf.Meta.DriverIDs),
WinnerDriverID: sf.WinnerDriverID,
WinnerName: sf.WinnerName,
BestLapMs: sf.BestLapMs,
}
return s.pg.InsertFinished(ctx, r)
}
// ---------------------------------------------------------------------------
// Race plans
// ---------------------------------------------------------------------------
// CreatePlanInput is what the HTTP handler hands to the service.
type CreatePlanInput struct {
ID string
Name string
TrackID string
MaxCars int
Laps int
TimeLimitS int
StartAtMs int64
IntervalS int
Count int
Enabled bool
}
// CreatePlan validates and persists a plan.
func (s *Service) CreatePlan(ctx context.Context, in CreatePlanInput) (RacePlan, error) {
if in.Name == "" {
return RacePlan{}, fmt.Errorf("%w: name required", ErrInvalidInput)
}
if in.MaxCars < 1 || in.MaxCars > 8 {
return RacePlan{}, fmt.Errorf("%w: max_cars must be 1..8", ErrInvalidInput)
}
if in.StartAtMs <= 0 {
return RacePlan{}, fmt.Errorf("%w: start_at_ms required", ErrInvalidInput)
}
if in.IntervalS < 0 {
return RacePlan{}, fmt.Errorf("%w: interval_s must be >= 0", ErrInvalidInput)
}
if in.Count < 0 {
return RacePlan{}, fmt.Errorf("%w: count must be >= 0", ErrInvalidInput)
}
if in.ID == "" {
in.ID = fmt.Sprintf("plan-%d-%d", s.now().UnixMilli(), time.Now().UnixNano()%1000)
}
if in.TrackID == "" {
in.TrackID = "default"
}
p := RacePlan{
ID: in.ID,
Name: in.Name,
TrackID: in.TrackID,
MaxCars: in.MaxCars,
Laps: in.Laps,
TimeLimitS: in.TimeLimitS,
StartAtMs: in.StartAtMs,
IntervalS: in.IntervalS,
Count: in.Count,
Enabled: in.Enabled,
NextFireMs: in.StartAtMs,
}
if err := s.pg.CreatePlan(ctx, p); err != nil {
return RacePlan{}, err
}
return s.pg.GetPlan(ctx, p.ID)
}
// ListPlans returns plans in start_at ascending order.
func (s *Service) ListPlans(ctx context.Context, cur Cursor, limit int) ([]RacePlan, error) {
if limit <= 0 {
limit = 50
}
return s.pg.ListPlans(ctx, cur, limit)
}
// DeletePlan removes a plan by id.
func (s *Service) DeletePlan(ctx context.Context, id string) error {
return s.pg.DeletePlan(ctx, id)
}
// ---------------------------------------------------------------------------
// Scheduler
// ---------------------------------------------------------------------------
// Scheduler materialises due race plans into lobby races every tick.
type Scheduler struct {
pg *PgStore
lobby *lobby.Service
tick time.Duration
stopCh chan struct{}
doneCh chan struct{}
once sync.Once
}
// NewScheduler creates a scheduler. tick default is 5s.
func NewScheduler(pg *PgStore, lb *lobby.Service, tick time.Duration) *Scheduler {
if tick <= 0 {
tick = 5 * time.Second
}
return &Scheduler{
pg: pg,
lobby: lb,
tick: tick,
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
// Run blocks until Stop is called. Safe to launch in a goroutine.
func (s *Scheduler) Run(ctx context.Context) {
defer close(s.doneCh)
t := time.NewTicker(s.tick)
defer t.Stop()
// Fire once on start so newly-enabled plans materialise promptly.
s.tickOnce(ctx)
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case <-t.C:
s.tickOnce(ctx)
}
}
}
// Stop signals the scheduler to exit.
func (s *Scheduler) Stop() {
s.once.Do(func() { close(s.stopCh) })
}
// Done blocks until the scheduler goroutine has returned.
func (s *Scheduler) Done() <-chan struct{} { return s.doneCh }
func (s *Scheduler) tickOnce(ctx context.Context) {
now := s.now().UnixMilli()
plans, err := s.pg.DuePlans(ctx, now, 32)
if err != nil {
return
}
for _, p := range plans {
meta, err := s.lobby.CreateRace(lobby.CreateRaceOptions{
Name: p.Name,
TrackID: p.TrackID,
MaxCars: p.MaxCars,
Laps: p.Laps,
TimeLimitS: p.TimeLimitS,
})
if err != nil {
// Could not create; advance anyway to avoid hot-loop on the
// same plan.
next := p.NextFireMs
if p.IntervalS > 0 {
next = next + int64(p.IntervalS)*1000
}
_ = s.pg.AdvancePlan(ctx, p.ID, next)
continue
}
// Compute next fire time.
next := p.NextFireMs
if p.IntervalS > 0 {
next = p.NextFireMs + int64(p.IntervalS)*1000
}
_ = s.advance(ctx, p, next)
// Auto-attach queued drivers to the new race (best-effort).
s.attachQueued(ctx, meta.ID)
}
}
func (s *Scheduler) advance(ctx context.Context, p RacePlan, nextFireMs int64) error {
if nextFireMs == 0 && p.IntervalS == 0 {
// Single-shot: disable.
return s.pg.SetPlanEnabled(ctx, p.ID, false)
}
return s.pg.AdvancePlan(ctx, p.ID, nextFireMs)
}
func (s *Scheduler) attachQueued(ctx context.Context, raceID string) {
entries, err := s.pg.ListQueueByRace(ctx, raceID)
if err != nil {
return
}
for _, q := range entries {
_, _ = s.lobby.AddDriver(q.DriverID, "", "") //nolint:errcheck // best-effort
_ = s.lobby.AddDriverToRace(q.DriverID, raceID)
_ = s.pg.Dequeue(ctx, q.DriverID, raceID)
}
}
func (s *Scheduler) now() time.Time { return time.Now() }