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
+109
View File
@@ -0,0 +1,109 @@
package stats
import (
"testing"
"time"
)
func TestConnectDisconnectCounts(t *testing.T) {
c := NewCollector()
c.Start()
c.OnConnect("d1", "Alice")
c.OnConnect("d2", "Bob")
c.OnDisconnect("d1", 5000)
s := c.Server()
if s.CurrentClients != 1 {
t.Errorf("CurrentClients: got %d", s.CurrentClients)
}
if s.PeakClients != 2 {
t.Errorf("PeakClients: got %d", s.PeakClients)
}
if s.TotalConnections != 2 {
t.Errorf("TotalConnections: got %d", s.TotalConnections)
}
if s.TotalDisconnects != 1 {
t.Errorf("TotalDisconnects: got %d", s.TotalDisconnects)
}
if s.UptimeMs < 0 {
t.Errorf("UptimeMs negative: %d", s.UptimeMs)
}
}
func TestLapAndDriverProfile(t *testing.T) {
c := NewCollector()
c.Start()
c.OnConnect("d1", "Alice")
c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: 12_345, TSMs: time.Now().UnixMilli()})
c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: 11_500, TSMs: time.Now().UnixMilli()})
c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: 13_000, TSMs: time.Now().UnixMilli()})
d := c.Driver("d1", "Alice")
if d.TotalLaps != 3 {
t.Errorf("TotalLaps: got %d", d.TotalLaps)
}
if d.BestLapMs != 11_500 {
t.Errorf("BestLapMs: got %d", d.BestLapMs)
}
if d.LastLapMs != 13_000 {
t.Errorf("LastLapMs: got %d", d.LastLapMs)
}
}
func TestRaceFinishedUpdatesProfile(t *testing.T) {
c := NewCollector()
c.Start()
c.OnConnect("d1", "Alice")
c.OnConnect("d2", "Bob")
c.OnRaceStarted([]string{"d1", "d2"})
c.OnRaceFinished(RaceResult{RaceID: "r1", DriverID: "d1", Position: 1, TotalLaps: 5, BestLapMs: 11_000, TotalTimeMs: 60_000})
c.OnRaceFinished(RaceResult{RaceID: "r1", DriverID: "d2", Position: 2, TotalLaps: 5, BestLapMs: 11_500, TotalTimeMs: 60_500})
d1 := c.Driver("d1", "")
if d1.Wins != 1 {
t.Errorf("Wins: got %d", d1.Wins)
}
if d1.BestLapMs != 11_000 {
t.Errorf("BestLapMs: got %d", d1.BestLapMs)
}
d2 := c.Driver("d2", "")
if d2.Wins != 0 || d2.Podiums != 1 {
t.Errorf("Podiums: got %d", d2.Podiums)
}
s := c.Server()
if s.TotalRacesStarted != 1 || s.TotalRacesFinished != 2 {
t.Errorf("counters: started=%d finished=%d", s.TotalRacesStarted, s.TotalRacesFinished)
}
}
func TestRTTWindowAverage(t *testing.T) {
c := NewCollector()
c.Start()
for _, v := range []int64{10, 20, 30, 40, 50} {
c.OnRTT(v)
}
s := c.Server()
if s.AvgRTTMs != 30 {
t.Errorf("AvgRTTMs: got %v want 30", s.AvgRTTMs)
}
if len(s.RecentRTTs) != 5 {
t.Errorf("RecentRTTs len: got %d", len(s.RecentRTTs))
}
}
func TestRecentLapsOrder(t *testing.T) {
c := NewCollector()
c.Start()
for i := 0; i < 5; i++ {
c.OnLapRecorded(LapRecord{RaceID: "r1", DriverID: "d1", LapMs: int64(10_000 + i), TSMs: int64(i)})
}
laps := c.RecentLaps(3)
if len(laps) != 3 {
t.Fatalf("got %d laps", len(laps))
}
if laps[0].TSMs < laps[1].TSMs {
t.Errorf("expected newest first, got order: %v", laps)
}
}
+363
View File
@@ -0,0 +1,363 @@
// Package stats collects and exposes server and per-driver metrics.
//
// PoC: in-memory only, atomic counters where possible, mutex around
// driver and race records. The data lives for the lifetime of the
// process. A future storage layer will swap this for Postgres/Redis.
//
// Public surface is split into:
// - lifecycle hooks (Connect/Disconnect/Race*/Lap*/Input*/RTT) —
// called from cmd/poc-server and from internal/control and lobby
// - Snapshot() — full server snapshot for /stats/detailed
// - DriverProfile(id) — per-driver summary
// - RecentLaps(n) — last N laps across all races
package stats
import (
"sync"
"sync/atomic"
"time"
)
// ServerStats is the top-level metrics snapshot.
type ServerStats struct {
StartedMs int64 `json:"started_ms"`
UptimeMs int64 `json:"uptime_ms"`
CurrentClients int64 `json:"current_clients"`
PeakClients int64 `json:"peak_clients"`
TotalConnections uint64 `json:"total_connections"`
TotalDisconnects uint64 `json:"total_disconnects"`
TotalRacesCreated uint64 `json:"total_races_created"`
TotalRacesStarted uint64 `json:"total_races_started"`
TotalRacesFinished uint64 `json:"total_races_finished"`
TotalLapsRecorded uint64 `json:"total_laps_recorded"`
TotalInputsRecv uint64 `json:"total_inputs_received"`
TotalSnapshotsSent uint64 `json:"total_snapshots_sent"`
SnapshotsDropped uint64 `json:"snapshots_dropped"`
AvgRTTMs float64 `json:"avg_rtt_ms"`
RecentRTTs []int64 `json:"recent_rtt_ms"` // windowed samples
}
// DriverProfile aggregates per-driver statistics.
type DriverProfile struct {
ID string `json:"id"`
Name string `json:"name"`
TotalRacesStarted uint64 `json:"total_races_started"`
TotalRacesFinished uint64 `json:"total_races_finished"`
TotalLaps uint64 `json:"total_laps"`
BestLapMs int64 `json:"best_lap_ms"`
LastLapMs int64 `json:"last_lap_ms"`
TotalDistanceM float64 `json:"total_distance_m"`
TotalPlaytimeMs int64 `json:"total_playtime_ms"`
Wins uint64 `json:"wins"`
Podiums uint64 `json:"podiums"`
LastSeenMs int64 `json:"last_seen_ms"`
}
// LapRecord is one lap, kept for /stats/recent.
type LapRecord struct {
RaceID string `json:"race_id"`
DriverID string `json:"driver_id"`
LapMs int64 `json:"lap_ms"`
Sector1Ms int64 `json:"sector1_ms"`
Sector2Ms int64 `json:"sector2_ms"`
Sector3Ms int64 `json:"sector3_ms"`
Position int `json:"position,omitempty"`
TSMs int64 `json:"ts_ms"`
}
// RaceResult is the final classification of a race for one driver.
type RaceResult struct {
RaceID string `json:"race_id"`
DriverID string `json:"driver_id"`
FinishedAtMs int64 `json:"finished_at_ms"`
Position int `json:"position"`
TotalLaps int `json:"total_laps"`
BestLapMs int64 `json:"best_lap_ms"`
TotalTimeMs int64 `json:"total_time_ms"`
DNF bool `json:"dnf"`
}
// collector owns the runtime state.
type collector struct {
startedMs atomic.Int64
currentClients atomic.Int64
peakClients atomic.Int64
totalConn atomic.Uint64
totalDisc atomic.Uint64
totalRacesC atomic.Uint64
totalRacesS atomic.Uint64
totalRacesF atomic.Uint64
totalLaps atomic.Uint64
totalInputs atomic.Uint64
totalSnapsSent atomic.Uint64
totalSnapDrops atomic.Uint64
// RTT moving window (last 32 samples).
rttMu sync.Mutex
rttWin []int64
}
// Collector is the public stats service.
type Collector struct {
c collector
mu sync.RWMutex
drivers map[string]*DriverProfile
laps []LapRecord // ring buffer
lapsHead int
lapsCap int
results []RaceResult
resultsMax int
}
// NewCollector returns a fresh collector.
func NewCollector() *Collector {
return &Collector{
c: collector{},
drivers: make(map[string]*DriverProfile),
lapsCap: 200,
laps: make([]LapRecord, 200),
resultsMax: 500,
results: make([]RaceResult, 0, 500),
}
}
// Start marks process start time. Call once.
func (c *Collector) Start() {
c.c.startedMs.Store(time.Now().UnixMilli())
}
// Lifecycle hooks --------------------------------------------------------
// OnConnect increments counters and starts a session.
func (c *Collector) OnConnect(clientID, name string) {
c.c.totalConn.Add(1)
cur := c.c.currentClients.Add(1)
for {
peak := c.c.peakClients.Load()
if cur <= peak || c.c.peakClients.CompareAndSwap(peak, cur) {
break
}
}
c.touchDriver(clientID, name)
}
// OnDisconnect decrements current and adds session duration.
func (c *Collector) OnDisconnect(clientID string, sessionMs int64) {
c.c.currentClients.Add(-1)
c.c.totalDisc.Add(1)
c.mu.Lock()
if d, ok := c.drivers[clientID]; ok {
d.TotalPlaytimeMs += sessionMs
d.LastSeenMs = time.Now().UnixMilli()
}
c.mu.Unlock()
}
// OnRaceCreated increments a counter.
func (c *Collector) OnRaceCreated() { c.c.totalRacesC.Add(1) }
// OnRaceStarted increments a counter and bumps driver race count.
func (c *Collector) OnRaceStarted(driverIDs []string) {
c.c.totalRacesS.Add(1)
c.mu.Lock()
for _, id := range driverIDs {
if d, ok := c.drivers[id]; ok {
d.TotalRacesStarted++
}
}
c.mu.Unlock()
}
// OnRaceFinished records final classification. Position is 1-based.
func (c *Collector) OnRaceFinished(result RaceResult) {
c.c.totalRacesF.Add(1)
c.mu.Lock()
defer c.mu.Unlock()
if d, ok := c.drivers[result.DriverID]; ok {
if !result.DNF {
d.TotalRacesFinished++
}
if result.Position == 1 {
d.Wins++
} else if result.Position >= 1 && result.Position <= 3 {
d.Podiums++
}
if result.BestLapMs > 0 && (d.BestLapMs == 0 || result.BestLapMs < d.BestLapMs) {
d.BestLapMs = result.BestLapMs
}
}
c.results = append(c.results, result)
if len(c.results) > c.resultsMax {
// drop oldest 10%
drop := c.resultsMax / 10
c.results = c.results[drop:]
}
}
// OnLapRecorded stores lap in ring buffer and updates driver totals.
func (c *Collector) OnLapRecorded(rec LapRecord) {
c.c.totalLaps.Add(1)
c.mu.Lock()
defer c.mu.Unlock()
if d, ok := c.drivers[rec.DriverID]; ok {
d.TotalLaps++
d.LastLapMs = rec.LapMs
if rec.LapMs > 0 && (d.BestLapMs == 0 || rec.LapMs < d.BestLapMs) {
d.BestLapMs = rec.LapMs
}
}
c.laps[c.lapsHead] = rec
c.lapsHead = (c.lapsHead + 1) % c.lapsCap
}
// OnInputProcessed increments input counter.
func (c *Collector) OnInputProcessed() { c.c.totalInputs.Add(1) }
// OnSnapshotSent increments snapshot counter and the drop counter.
func (c *Collector) OnSnapshotSent(sent, dropped uint64) {
c.c.totalSnapsSent.Add(sent)
c.c.totalSnapDrops.Add(dropped)
}
// OnRTT adds a sample to the moving window.
func (c *Collector) OnRTT(rttMs int64) {
if rttMs < 0 {
return
}
c.c.rttMu.Lock()
defer c.c.rttMu.Unlock()
c.c.rttWin = append(c.c.rttWin, rttMs)
if len(c.c.rttWin) > 32 {
c.c.rttWin = c.c.rttWin[len(c.c.rttWin)-32:]
}
}
// OnDistance accumulates meters driven (called per snapshot).
func (c *Collector) OnDistance(driverID string, meters float64) {
if meters <= 0 {
return
}
c.mu.Lock()
if d, ok := c.drivers[driverID]; ok {
d.TotalDistanceM += meters
}
c.mu.Unlock()
}
// Snapshots ---------------------------------------------------------------
// Server returns the current server-level metrics.
func (c *Collector) Server() ServerStats {
now := time.Now().UnixMilli()
c.c.rttMu.Lock()
window := append([]int64(nil), c.c.rttWin...)
c.c.rttMu.Unlock()
var avg float64
if len(window) > 0 {
var sum int64
for _, v := range window {
sum += v
}
avg = float64(sum) / float64(len(window))
}
return ServerStats{
StartedMs: c.c.startedMs.Load(),
UptimeMs: now - c.c.startedMs.Load(),
CurrentClients: c.c.currentClients.Load(),
PeakClients: c.c.peakClients.Load(),
TotalConnections: c.c.totalConn.Load(),
TotalDisconnects: c.c.totalDisc.Load(),
TotalRacesCreated: c.c.totalRacesC.Load(),
TotalRacesStarted: c.c.totalRacesS.Load(),
TotalRacesFinished: c.c.totalRacesF.Load(),
TotalLapsRecorded: c.c.totalLaps.Load(),
TotalInputsRecv: c.c.totalInputs.Load(),
TotalSnapshotsSent: c.c.totalSnapsSent.Load(),
SnapshotsDropped: c.c.totalSnapDrops.Load(),
AvgRTTMs: avg,
RecentRTTs: window,
}
}
// Driver returns the profile for one driver, creating it if missing.
func (c *Collector) Driver(id, name string) DriverProfile {
c.mu.Lock()
defer c.mu.Unlock()
d, ok := c.drivers[id]
if !ok {
d = &DriverProfile{ID: id, Name: name}
c.drivers[id] = d
} else if name != "" {
d.Name = name
}
return *d
}
// Drivers returns a copy of all driver profiles.
func (c *Collector) Drivers() []DriverProfile {
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]DriverProfile, 0, len(c.drivers))
for _, d := range c.drivers {
out = append(out, *d)
}
return out
}
// RecentLaps returns the last N lap records (newest first).
func (c *Collector) RecentLaps(n int) []LapRecord {
if n <= 0 || n > c.lapsCap {
n = c.lapsCap
}
c.mu.RLock()
defer c.mu.RUnlock()
out := make([]LapRecord, 0, n)
// Walk backwards from lapsHead.
for i := 0; i < c.lapsCap && len(out) < n; i++ {
idx := (c.lapsHead - 1 - i + c.lapsCap) % c.lapsCap
if c.laps[idx].TSMs == 0 {
continue
}
out = append(out, c.laps[idx])
}
return out
}
// RecentResults returns the last N race results.
func (c *Collector) RecentResults(n int) []RaceResult {
c.mu.RLock()
defer c.mu.RUnlock()
if n <= 0 || n > len(c.results) {
n = len(c.results)
}
out := make([]RaceResult, n)
copy(out, c.results[len(c.results)-n:])
// reverse for newest-first
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
// helpers ----------------------------------------------------------------
// touchDriver creates a profile if missing and updates LastSeenMs.
func (c *Collector) touchDriver(id, name string) {
c.mu.Lock()
defer c.mu.Unlock()
d, ok := c.drivers[id]
if !ok {
c.drivers[id] = &DriverProfile{ID: id, Name: name, LastSeenMs: time.Now().UnixMilli()}
return
}
d.LastSeenMs = time.Now().UnixMilli()
if name != "" && d.Name == "" {
d.Name = name
}
}