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
+57
View File
@@ -0,0 +1,57 @@
package races
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
)
// Cursor is a keyset cursor: opaque to the client, encoded as
// base64url("<ms>:<id>") by Encode. Order: descending by ms, then by id.
//
// Empty cursor = "start from the newest".
type Cursor struct {
Ms int64
ID string
}
// String returns a human-readable representation (for debug logs).
func (c Cursor) String() string {
return fmt.Sprintf("%d:%s", c.Ms, c.ID)
}
// IsZero reports whether the cursor is the zero value.
func (c Cursor) IsZero() bool {
return c.Ms == 0 && c.ID == ""
}
// Encode renders the cursor for an HTTP query string.
func (c Cursor) Encode() string {
if c.IsZero() {
return ""
}
raw := c.String()
return base64.RawURLEncoding.EncodeToString([]byte(raw))
}
// DecodeCursor parses a cursor produced by Cursor.Encode.
// Returns the zero cursor on empty input.
func DecodeCursor(s string) (Cursor, error) {
if s == "" {
return Cursor{}, nil
}
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return Cursor{}, fmt.Errorf("%w: bad cursor encoding: %v", ErrInvalidInput, err)
}
parts := strings.SplitN(string(raw), ":", 2)
if len(parts) != 2 {
return Cursor{}, fmt.Errorf("%w: cursor must be <ms>:<id>", ErrInvalidInput)
}
ms, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return Cursor{}, fmt.Errorf("%w: cursor ms: %v", ErrInvalidInput, err)
}
return Cursor{Ms: ms, ID: parts[1]}, nil
}
+75
View File
@@ -0,0 +1,75 @@
package races
import (
"context"
"github.com/x0gp/server/internal/lobby"
)
// LiveStore is a thin facade over PgStore that exposes only the live
// race persistence methods. It implements the lobby.Persistence
// contract so the lobby can write through it.
//
// Methods on this type are kept separate from the rest of PgStore
// purely for clarity: the lobby-facing surface is small and
// well-defined. All actual work is delegated to PgStore.
type LiveStore struct {
pg *PgStore
}
// NewLiveStore wraps an existing PgStore.
func NewLiveStore(pg *PgStore) *LiveStore { return &LiveStore{pg: pg} }
func (s *LiveStore) UpsertRace(ctx context.Context, r lobby.RaceMeta) error {
return s.pg.UpsertLive(ctx, r)
}
func (s *LiveStore) DeleteRace(ctx context.Context, raceID string) error {
return s.pg.DeleteLive(ctx, raceID)
}
func (s *LiveStore) AddDriver(ctx context.Context, raceID, driverID string, slot int) error {
return s.pg.AddLiveDriver(ctx, raceID, driverID, slot)
}
func (s *LiveStore) RemoveDriver(ctx context.Context, raceID, driverID string) error {
return s.pg.RemoveLiveDriver(ctx, raceID, driverID)
}
func (s *LiveStore) SetRaceStatus(ctx context.Context, raceID string, status lobby.RaceStatus, startedMs int64) error {
return s.pg.SetLiveStatus(ctx, raceID, status, startedMs)
}
func (s *LiveStore) UpsertDriver(ctx context.Context, d lobby.DriverMeta) error {
return s.pg.UpsertLobbyDriver(ctx, d)
}
func (s *LiveStore) DeleteDriver(ctx context.Context, driverID string) error {
return s.pg.DeleteLobbyDriver(ctx, driverID)
}
// ListAllRaces returns every live row in the unified races table.
// Used by RestoreFromDB to rehydrate the in-memory lobby.
func (s *LiveStore) ListAllRaces(ctx context.Context) ([]lobby.RaceMeta, error) {
return s.pg.ListAllRaces(ctx)
}
// ListAllDrivers returns all lobby drivers from the lobby_drivers table.
func (s *LiveStore) ListAllDrivers(ctx context.Context) ([]lobby.DriverMeta, error) {
return s.pg.ListLobbyDrivers(ctx)
}
// ListLivePaged exposes a keyset page of live races.
func (s *LiveStore) ListLivePaged(ctx context.Context, statuses []string, trackID string, cur Cursor, limit int) ([]lobby.RaceMeta, error) {
return s.pg.ListLivePaged(ctx, statuses, trackID, cur, limit)
}
// ListUpcoming returns the next N open live races.
func (s *LiveStore) ListUpcoming(ctx context.Context, limit int) ([]lobby.RaceMeta, error) {
return s.pg.ListLiveUpcoming(ctx, limit)
}
// Exec exposes raw SQL for the seeder.
func (s *LiveStore) Exec(ctx context.Context, sql string) (int64, error) {
return s.pg.Exec(ctx, sql)
}
+562
View File
@@ -0,0 +1,562 @@
package seed
import (
"context"
"fmt"
"math/rand"
"time"
"github.com/x0gp/server/internal/clans"
"github.com/x0gp/server/internal/drivers"
"github.com/x0gp/server/internal/lobby"
"github.com/x0gp/server/internal/races"
)
// DefaultCounts are the volumes produced by Run.
type DefaultCounts struct {
Finished int // default 30
Live int // default 5
Plans int // default 5
}
// DefaultSeed is the deterministic seed.
const DefaultSeed = 0xA1B2C3D4
// DefaultNow is overridable for tests.
var DefaultNow = time.Now
// Track catalogue used by the seeder. Loaded from the tracks table at
// run time. If no tracks exist, the seeder falls back to ["default"].
var fallbackTracks = []string{"default"}
// Default driver nicknames used when the drivers table is empty. Three
// uppercase letters each (per the drivers table constraint).
var defaultDriverSeeds = []struct {
nick string
name string
}{
{"ACE", "Alice"},
{"BOB", "Bob"},
{"CAR", "Carol"},
{"DAV", "Dave"},
{"EVE", "Eve"},
{"FRA", "Frank"},
{"GRA", "Grace"},
{"HEI", "Heidi"},
}
// Default clans.
var defaultClanSeeds = []struct {
tag string
name string
}{
{"ACE", "Ace Racing"},
{"RND", "Rounders"},
{"SPD", "Speed Demons"},
}
// Options configures Run.
type Options struct {
Counts DefaultCounts
Seed int64
Reset bool
Now time.Time
}
// Summary is the result of Run.
type Summary struct {
FinishedInserted int
LiveCreated int
PlansInserted int
QueueInserted int
Duration time.Duration
}
// Runner is a self-contained seeder wired to the package's pg store and
// lobby. Hold one per process; safe to call Run multiple times.
type Runner struct {
pg *races.PgStore
lb *lobby.Service
live *races.LiveStore
clansPg *clans.PgStore
driversPg *drivers.PgStore
opt Options
rng *rand.Rand
now time.Time
// Filled by loadInputs.
trackIDs []string
driverIDs []string // driver ids in the order they exist after seed
driverInfo []seedDriver // id, nick, name, clanID, clanTag
clanByID map[string]string // clan id -> tag (for fast profile updates)
}
type seedDriver struct {
ID string
Nickname string
Name string
ClanID string
ClanTag string
}
// NewRunner constructs a Runner.
func NewRunner(pg *races.PgStore, lb *lobby.Service, live *races.LiveStore, clansPg *clans.PgStore, driversPg *drivers.PgStore) *Runner {
return &Runner{pg: pg, lb: lb, live: live, clansPg: clansPg, driversPg: driversPg}
}
// Run executes the seed against the supplied services.
//
// Order of operations:
//
// 1. (optional) reset: TRUNCATE finished_races, race_plans, race_queue;
// remove all live races from the lobby; remove the auto-seeded drivers
// from the lobby.
// 2. Insert N finished races into Postgres. These do NOT touch the lobby.
// 3. Create M live races in the lobby (mix of statuses).
// 4. Insert K race plans into Postgres. The scheduler will materialise
// them on its next tick (start_at_ms is in the future).
// 5. Enqueue the first two drivers for the next 2 live races.
func (r *Runner) Run(ctx context.Context, opt Options) (Summary, error) {
if opt.Seed == 0 {
opt.Seed = DefaultSeed
}
if opt.Now.IsZero() {
opt.Now = DefaultNow()
}
if opt.Counts.Finished == 0 {
opt.Counts.Finished = 30
}
if opt.Counts.Live == 0 {
opt.Counts.Live = 5
}
if opt.Counts.Plans == 0 {
opt.Counts.Plans = 5
}
start := time.Now()
var s Summary
if opt.Reset {
if err := r.resetAll(ctx); err != nil {
return s, fmt.Errorf("reset: %w", err)
}
}
r.rng = rand.New(rand.NewSource(opt.Seed))
r.now = opt.Now
// 0. Seed drivers and clans (idempotent). Then load real track ids
// from the catalog so race_plans / finished_races only reference
// tracks that actually exist in the DB.
if err := r.seedDriversAndClans(ctx); err != nil {
return s, fmt.Errorf("seed drivers/clans: %w", err)
}
if err := r.loadTracks(ctx); err != nil {
return s, fmt.Errorf("load tracks: %w", err)
}
if err := r.loadDrivers(ctx); err != nil {
return s, fmt.Errorf("load drivers: %w", err)
}
// 1. Finished races.
for i := 0; i < opt.Counts.Finished; i++ {
row := r.makeFinished(i)
if err := r.pg.InsertFinished(ctx, row); err != nil {
return s, fmt.Errorf("insert finished %d: %w", i, err)
}
s.FinishedInserted++
}
// 2. Live races in the lobby.
statuses := []lobby.RaceStatus{
lobby.RaceStatusLobby,
lobby.RaceStatusLobby,
lobby.RaceStatusCountdown,
lobby.RaceStatusRacing,
lobby.RaceStatusLobby,
}
for i := 0; i < opt.Counts.Live && i < len(statuses); i++ {
meta, err := r.createLive(statuses[i], i)
if err != nil {
return s, fmt.Errorf("create live %d: %w", i, err)
}
if statuses[i] != lobby.RaceStatusLobby {
if err := r.lb.SetRaceStatus(meta.ID, statuses[i]); err != nil {
return s, fmt.Errorf("set status %s: %w", meta.ID, err)
}
// Flush the new status synchronously so the DB row is in
// sync with in-memory (mirror goroutines may not have run
// yet).
if r.live != nil {
flushCtx, flushCancel := context.WithTimeout(ctx, 2*time.Second)
cur, err := r.lb.GetRace(meta.ID)
if err == nil {
_ = r.live.SetRaceStatus(flushCtx, cur.ID, cur.Status, cur.StartedMs)
}
flushCancel()
}
}
s.LiveCreated++
// First two live races: enqueue first two drivers on this race.
if i < 2 {
for _, d := range r.driverInfo[:2] {
if _, err := r.pg.Enqueue(ctx, d.ID, meta.ID, ""); err != nil {
return s, fmt.Errorf("enqueue %s: %w", d.ID, err)
}
s.QueueInserted++
}
}
}
// 3. Race plans. Mix of one-shot and recurring.
planShapes := []planShape{
{intervalS: 0, offsetS: 5 * 60}, // one-shot in 5 min
{intervalS: 60, offsetS: 30 * 60}, // every minute, start in 30 min
{intervalS: 3600, count: 24, offsetS: 60 * 60}, // hourly x24, start in 1h
{intervalS: 0, offsetS: 6 * 3600}, // one-shot in 6h
{intervalS: 86400, offsetS: 24 * 3600}, // daily, start tomorrow
}
for i := 0; i < opt.Counts.Plans && i < len(planShapes); i++ {
ps := planShapes[i]
p := r.makePlan(i, ps)
if err := r.pg.CreatePlan(ctx, p); err != nil {
return s, fmt.Errorf("create plan %d: %w", i, err)
}
s.PlansInserted++
}
s.Duration = time.Since(start)
return s, nil
}
type planShape struct {
intervalS int
count int
offsetS int
}
// resetAll wipes the seed-managed rows in the unified races table
// (only finished | cancelled; live rows are removed via lobby
// cascade), plus the plan and queue tables. Drivers and clans are
// kept (they're independent of races).
func (r *Runner) resetAll(ctx context.Context) error {
if _, err := r.pg.Exec(ctx,
`DELETE FROM races WHERE status IN ('finished','cancelled')`); err != nil {
return err
}
if _, err := r.pg.Exec(ctx,
`TRUNCATE TABLE race_plans, race_queue`); err != nil {
return err
}
for _, m := range r.lb.ListRaces() {
_ = r.lb.DeleteRace(m.ID)
}
if _, err := r.pg.Exec(ctx, `TRUNCATE TABLE lobby_drivers`); err != nil {
return err
}
return nil
}
// seedDriversAndClans inserts the default driver and clan set if the
// tables are empty. Idempotent: existing rows are left alone.
func (r *Runner) seedDriversAndClans(ctx context.Context) error {
clanCount, err := r.clansPg.Count(ctx)
if err != nil {
return fmt.Errorf("count clans: %w", err)
}
if clanCount == 0 {
now := time.Now().UnixMilli()
for i, cs := range defaultClanSeeds {
c := clans.Clan{
ID: fmt.Sprintf("clan-seed-%03d", i+1),
Tag: cs.tag,
Name: cs.name,
AvatarURL: fmt.Sprintf("https://cdn.example.com/clans/%s.png", cs.tag),
CreatedMs: now,
UpdatedMs: now,
}
if err := r.clansPg.Create(ctx, c); err != nil {
return fmt.Errorf("insert clan %s: %w", cs.tag, err)
}
}
}
drvCount, err := r.driversPg.Count(ctx)
if err != nil {
return fmt.Errorf("count drivers: %w", err)
}
if drvCount == 0 {
now := time.Now().UnixMilli()
// Round-robin: each driver assigned to one of the seeded clans.
clanIDs := make([]string, 0, len(defaultClanSeeds))
for i := range defaultClanSeeds {
clanIDs = append(clanIDs, fmt.Sprintf("clan-seed-%03d", i+1))
}
for i, ds := range defaultDriverSeeds {
d := drivers.Driver{
ID: fmt.Sprintf("driver-seed-%03d", i+1),
Nickname: ds.nick,
Name: ds.name,
AvatarURL: fmt.Sprintf("https://cdn.example.com/drivers/%s.png", ds.nick),
ClanID: clanIDs[i%len(clanIDs)],
CreatedMs: now,
UpdatedMs: now,
}
if err := r.driversPg.Create(ctx, d); err != nil {
return fmt.Errorf("insert driver %s: %w", ds.nick, err)
}
}
}
return nil
}
// loadTracks fills r.trackIDs from the tracks table. Falls back to
// fallbackTracks if the table is empty.
func (r *Runner) loadTracks(ctx context.Context) error {
ids, err := r.pg.ListTrackIDs(ctx)
if err != nil {
return err
}
if len(ids) == 0 {
ids = fallbackTracks
}
r.trackIDs = ids
return nil
}
// loadDrivers fills r.driverIDs and r.driverInfo from the drivers table
// in the DB, then propagates the profile (nickname/avatar/clan) to the
// in-memory lobby drivers.
func (r *Runner) loadDrivers(ctx context.Context) error {
// Pull all drivers, ordered by nickname.
clanByID := make(map[string]string)
clansList, err := r.clansPg.List(ctx, 200, 0)
if err != nil {
return fmt.Errorf("list clans: %w", err)
}
for _, c := range clansList {
clanByID[c.ID] = c.Tag
}
r.clanByID = clanByID
drvList, err := r.driversPg.List(ctx, 200, 0, "")
if err != nil {
return fmt.Errorf("list drivers: %w", err)
}
r.driverIDs = r.driverIDs[:0]
r.driverInfo = r.driverInfo[:0]
for _, d := range drvList {
r.driverIDs = append(r.driverIDs, d.ID)
r.driverInfo = append(r.driverInfo, seedDriver{
ID: d.ID,
Nickname: d.Nickname,
Name: d.Name,
ClanID: d.ClanID,
ClanTag: clanByID[d.ClanID],
})
}
return nil
}
// ---------------------------------------------------------------------------
// Generators
// ---------------------------------------------------------------------------
func (r *Runner) makeFinished(idx int) races.FinishedRace {
rng := r.rng
now := r.now
// Spread across the last 30 days.
back := time.Duration(30*24-rng.Intn(30*24-1)) * time.Hour
finishedMs := now.Add(-back).UnixMilli()
startedMs := finishedMs - randDuration(rng, 90, 600).Milliseconds()
track := r.trackIDs[rng.Intn(len(r.trackIDs))]
laps := trackLaps(track)
// Pick 2..4 random drivers from the seeded set.
shuffled := append([]seedDriver(nil), r.driverInfo...)
rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
n := 2 + rng.Intn(3) // 2..4 drivers
if n > len(shuffled) {
n = len(shuffled)
}
picked := shuffled[:n]
driverIDs := make([]string, n)
for i, d := range picked {
driverIDs[i] = d.ID
}
winner := picked[rng.Intn(len(picked))]
totalLaps := laps
if rng.Intn(5) == 0 {
// ~20% DNF/partial.
totalLaps = rng.Intn(laps)
}
bestLapMs := int64(20_000 + rng.Intn(8_000))
// Podium: top-3 by total time. The winner always finishes; the
// remaining drivers are sorted by a per-driver total time derived
// from the winner's best lap.
perDriver := make([]int64, len(picked))
for i, d := range picked {
if d.ID == winner.ID {
perDriver[i] = int64(totalLaps) * bestLapMs
} else {
perDriver[i] = int64(totalLaps)*bestLapMs + int64(2+rng.Intn(15))*1000
}
}
type dp struct {
d seedDriver
time int64
}
all := make([]dp, len(picked))
for i := range picked {
all[i] = dp{picked[i], perDriver[i]}
}
// Insertion sort by time ascending.
for i := 1; i < len(all); i++ {
for j := i; j > 0 && all[j-1].time > all[j].time; j-- {
all[j-1], all[j] = all[j], all[j-1]
}
}
podium := make([]races.PodiumEntry, 0, 3)
for i := 0; i < len(all) && i < 3; i++ {
podium = append(podium, races.PodiumEntry{
Position: i + 1,
DriverID: all[i].d.ID,
Name: all[i].d.Nickname,
TotalTimeMs: all[i].time,
})
}
return races.FinishedRace{
ID: fmt.Sprintf("seed-finished-%03d", idx+1),
Name: fmt.Sprintf("Seed Race #%d (%s)", idx+1, track),
TrackID: track,
MaxCars: 4,
Laps: laps,
TimeLimitS: 0,
DriverIDs: driverIDs,
Status: "finished",
CreatedMs: finishedMs - randDuration(rng, 5, 120).Milliseconds(),
StartedMs: startedMs,
FinishedMs: finishedMs,
DurationMs: finishedMs - startedMs,
TotalLaps: totalLaps,
TotalDrivers: len(driverIDs),
WinnerDriverID: winner.ID,
WinnerName: winner.Nickname,
BestLapMs: bestLapMs,
Podium: podium,
}
}
func (r *Runner) createLive(status lobby.RaceStatus, idx int) (lobby.RaceMeta, error) {
rng := r.rng
track := r.trackIDs[rng.Intn(len(r.trackIDs))]
meta, err := r.lb.CreateRace(lobby.CreateRaceOptions{
Name: fmt.Sprintf("Live %s #%d", track, idx+1),
TrackID: track,
MaxCars: 4,
Laps: trackLaps(track),
TimeLimitS: 0,
})
if err != nil {
return lobby.RaceMeta{}, err
}
// Add 1..3 random drivers into the race. We re-add them to refresh
// any prior lobby state, then attach the profile (nickname/avatar/
// clan) so the WS broadcast carries rich metadata.
shuffled := append([]seedDriver(nil), r.driverInfo...)
rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
n := 1 + rng.Intn(3)
if n > len(shuffled) {
n = len(shuffled)
}
for i := 0; i < n; i++ {
d := shuffled[i]
r.lb.RemoveDriver(d.ID)
if _, err := r.lb.AddDriver(d.ID, d.Name, ""); err != nil {
continue
}
r.lb.SetDriverProfile(d.ID, d.Nickname, "", d.ClanID, d.ClanTag)
if err := r.lb.AddDriverToRace(d.ID, meta.ID); err != nil {
// ignore: race may be at capacity
}
}
// Synchronously flush the race to the DB so the persistence mirror
// (which uses detached goroutines) doesn't race with subsequent
// status changes. The mirror calls in lobby.Service are still
// fired; this upsert is a guarantee, not a replacement.
if r.live != nil {
flushCtx, flushCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer flushCancel()
// Re-fetch the race from the in-memory lobby to get the latest
// driver list and started_ms.
cur, err := r.lb.GetRace(meta.ID)
if err == nil {
_ = r.live.UpsertRace(flushCtx, cur)
for slot, did := range cur.DriverIDs {
_ = r.live.AddDriver(flushCtx, cur.ID, did, slot)
}
}
}
_ = status
return meta, nil
}
func (r *Runner) makePlan(idx int, ps planShape) races.RacePlan {
rng := r.rng
now := r.now
track := r.trackIDs[rng.Intn(len(r.trackIDs))]
startAt := now.Add(time.Duration(ps.offsetS) * time.Second).UnixMilli()
return races.RacePlan{
ID: fmt.Sprintf("seed-plan-%03d", idx+1),
Name: fmt.Sprintf("Seed Plan #%d (%s)", idx+1, track),
TrackID: track,
MaxCars: 4,
Laps: trackLaps(track),
TimeLimitS: 0,
StartAtMs: startAt,
IntervalS: ps.intervalS,
Count: ps.count,
Enabled: true,
NextFireMs: startAt,
}
}
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
func trackLaps(track string) int {
switch track {
case "nordschleife":
return 3
case "monza":
return 12
case "spa":
return 10
case "silverstone":
return 8
case "suzuka":
return 9
default:
return 5
}
}
func randDuration(rng *rand.Rand, loSec, hiSec int) time.Duration {
return time.Duration(loSec+rng.Intn(hiSec-loSec+1)) * time.Second
}
func removeFrom(in []string, v string) []string {
out := make([]string, 0, len(in)-1)
for _, x := range in {
if x != v {
out = append(out, x)
}
}
return out
}
+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() }
+803
View File
@@ -0,0 +1,803 @@
package races
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/x0gp/server/internal/lobby"
)
// PodiumEntry is one row in the top-3 of a finished race. Ordered by
// Position (1..3). DriverID/Name are denormalised from the future
// drivers table; TotalTimeMs is the driver's total race time.
type PodiumEntry struct {
Position int `json:"position" example:"1"`
DriverID string `json:"driver_id" example:"driver-alice"`
Name string `json:"name" example:"driver-alice"`
TotalTimeMs int64 `json:"total_time_ms" example:"123456"`
}
// FinishedRace is the on-disk shape of a completed race. Mirrors the
// lobby.RaceMeta plus outcome fields populated by the engine at finish.
type FinishedRace struct {
ID string
Name string
TrackID string
MaxCars int
Laps int
TimeLimitS int
DriverIDs []string
Status string // "finished" | "cancelled"
CreatedMs int64
StartedMs int64
FinishedMs int64
DurationMs int64
TotalLaps int
TotalDrivers int
WinnerDriverID string
WinnerName string
BestLapMs int64
Podium []PodiumEntry
}
// RacePlan is a recurring or one-off scheduled race description.
type RacePlan struct {
ID string
Name string
TrackID string
MaxCars int
Laps int
TimeLimitS int
StartAtMs int64
IntervalS int
Count int
Enabled bool
CreatedMs int64
UpdatedMs int64
NextFireMs int64
FiresDone int
}
// QueueEntry is a single (driver, race) subscription.
type QueueEntry struct {
DriverID string
RaceID string
PlanID string
EnqueuedMs int64
}
// PgStore is the Postgres-backed half of the races package.
type PgStore struct {
pool *pgxpool.Pool
}
// NewPgStore wraps an open pgxpool.
func NewPgStore(pool *pgxpool.Pool) *PgStore {
return &PgStore{pool: pool}
}
// Exec exposes a raw Exec for the seeder / maintenance scripts.
func (s *PgStore) Exec(ctx context.Context, sql string) (int64, error) {
tag, err := s.pool.Exec(ctx, sql)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// ListTrackIDs returns all track ids from the tracks table. Used by
// the seeder to bind race_plans/finished_races to real catalog entries.
func (s *PgStore) ListTrackIDs(ctx context.Context) ([]string, error) {
rows, err := s.pool.Query(ctx, `SELECT id FROM tracks ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list track ids: %w", err)
}
defer rows.Close()
out := make([]string, 0)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// Live race surface (status in lobby | countdown | racing).
//
// The unified `races` table holds both live and finished rows. Methods
// below are the write-side mirror for lobby.Service; reads flow through
// races.Service via ListRacesPaged (status filter) and ListUpcoming.
// ---------------------------------------------------------------------------
// UpsertLive mirrors a lobby.RaceMeta row. Only used for status in
// (lobby, countdown, racing). Status is enforced at the call site.
func (s *PgStore) UpsertLive(ctx context.Context, r lobby.RaceMeta) error {
now := time.Now().UnixMilli()
_, err := s.pool.Exec(ctx, `
INSERT INTO races
(id, name, track_id, max_cars, laps, time_limit_s, status,
created_ms, started_ms, finished_ms, updated_ms, podium)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10, '[]'::jsonb)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
track_id = EXCLUDED.track_id,
max_cars = EXCLUDED.max_cars,
laps = EXCLUDED.laps,
time_limit_s = EXCLUDED.time_limit_s,
status = EXCLUDED.status,
started_ms = EXCLUDED.started_ms,
updated_ms = EXCLUDED.updated_ms`,
r.ID, r.Name, r.TrackID, r.MaxCars, r.Laps, r.TimeLimitS, string(r.Status),
r.CreatedMs, r.StartedMs, now)
if err != nil {
return fmt.Errorf("upsert live race %s: %w", r.ID, err)
}
return nil
}
// DeleteLive removes a live race row. The FK from race_drivers has
// CASCADE so the related drivers are also removed.
func (s *PgStore) DeleteLive(ctx context.Context, raceID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM races WHERE id = $1 AND status IN ('lobby','countdown','racing')`, raceID)
return err
}
// AddLiveDriver adds a driver to a live race (slot=position). Idempotent.
func (s *PgStore) AddLiveDriver(ctx context.Context, raceID, driverID string, slot int) error {
now := time.Now().UnixMilli()
_, err := s.pool.Exec(ctx, `
INSERT INTO race_drivers (race_id, driver_id, slot, joined_ms)
VALUES ($1, $2, $3, $4)
ON CONFLICT (race_id, driver_id) DO UPDATE SET slot = EXCLUDED.slot`,
raceID, driverID, slot, now)
if err != nil {
return fmt.Errorf("add live driver %s to %s: %w", driverID, raceID, err)
}
return nil
}
// RemoveLiveDriver removes a driver from a live race.
func (s *PgStore) RemoveLiveDriver(ctx context.Context, raceID, driverID string) error {
_, err := s.pool.Exec(ctx,
`DELETE FROM race_drivers WHERE race_id = $1 AND driver_id = $2`,
raceID, driverID)
return err
}
// SetLiveStatus updates the status (and started_ms) of a live race.
func (s *PgStore) SetLiveStatus(ctx context.Context, raceID string, status lobby.RaceStatus, startedMs int64) error {
now := time.Now().UnixMilli()
_, err := s.pool.Exec(ctx, `
UPDATE races
SET status = $2, started_ms = $3, updated_ms = $4
WHERE id = $1 AND status IN ('lobby','countdown','racing')`,
raceID, string(status), startedMs, now)
return err
}
// ListLivePaged returns one keyset page of live races.
func (s *PgStore) ListLivePaged(ctx context.Context, statuses []string, trackID string, cur Cursor, limit int) ([]lobby.RaceMeta, error) {
if limit <= 0 {
limit = 50
}
args := []any{}
conds := []string{"status IN ('lobby','countdown','racing')"}
if len(statuses) > 0 {
args = append(args, statuses)
conds = append(conds, fmt.Sprintf("status = ANY($%d)", len(args)))
}
if trackID != "" {
args = append(args, trackID)
conds = append(conds, fmt.Sprintf("track_id = $%d", len(args)))
}
if !cur.IsZero() {
args = append(args, cur.Ms, cur.ID)
conds = append(conds, fmt.Sprintf("(COALESCE(started_ms, created_ms), id) < ($%d, $%d)", len(args)-1, len(args)))
}
args = append(args, limit+1)
q := `SELECT id, name, track_id, max_cars, laps, time_limit_s, status,
created_ms, started_ms
FROM races
WHERE ` + joinAnd(conds) + `
ORDER BY COALESCE(started_ms, created_ms) DESC, id ASC
LIMIT $` + fmt.Sprintf("%d", len(args))
rows, err := s.pool.Query(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]lobby.RaceMeta, 0)
for rows.Next() {
var r lobby.RaceMeta
var status string
if err := rows.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS,
&status, &r.CreatedMs, &r.StartedMs); err != nil {
return nil, err
}
r.Status = lobby.RaceStatus(status)
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range out {
ids, err := s.listDriverIDs(ctx, out[i].ID)
if err != nil {
return nil, err
}
out[i].DriverIDs = ids
}
return out, nil
}
// ListLiveUpcoming returns up to limit live races in (lobby, countdown)
// ordered soonest first.
func (s *PgStore) ListLiveUpcoming(ctx context.Context, limit int) ([]lobby.RaceMeta, error) {
if limit <= 0 {
limit = 3
}
rows, err := s.pool.Query(ctx, `
SELECT id, name, track_id, max_cars, laps, time_limit_s, status,
created_ms, started_ms
FROM races
WHERE status IN ('lobby', 'countdown')
ORDER BY COALESCE(started_ms, created_ms) ASC, id ASC
LIMIT $1`, limit+1)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]lobby.RaceMeta, 0)
for rows.Next() {
var r lobby.RaceMeta
var status string
if err := rows.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS,
&status, &r.CreatedMs, &r.StartedMs); err != nil {
return nil, err
}
r.Status = lobby.RaceStatus(status)
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range out {
ids, err := s.listDriverIDs(ctx, out[i].ID)
if err != nil {
return nil, err
}
out[i].DriverIDs = ids
}
return out, nil
}
// GetLive fetches a single live race.
func (s *PgStore) GetLive(ctx context.Context, id string) (lobby.RaceMeta, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, name, track_id, max_cars, laps, time_limit_s, status,
created_ms, started_ms
FROM races WHERE id = $1 AND status IN ('lobby','countdown','racing')`, id)
var r lobby.RaceMeta
var status string
if err := row.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS,
&status, &r.CreatedMs, &r.StartedMs); err != nil {
return lobby.RaceMeta{}, err
}
r.Status = lobby.RaceStatus(status)
ids, err := s.listDriverIDs(ctx, id)
if err != nil {
return lobby.RaceMeta{}, err
}
r.DriverIDs = ids
return r, nil
}
// ListAllRaces returns every row in the races table (live+finished).
// Used by RestoreFromDB to rehydrate the lobby.
func (s *PgStore) ListAllRaces(ctx context.Context) ([]lobby.RaceMeta, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, name, track_id, max_cars, laps, time_limit_s, status,
created_ms, started_ms
FROM races
WHERE status IN ('lobby','countdown','racing')
ORDER BY created_ms ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]lobby.RaceMeta, 0)
for rows.Next() {
var r lobby.RaceMeta
var status string
if err := rows.Scan(&r.ID, &r.Name, &r.TrackID, &r.MaxCars, &r.Laps, &r.TimeLimitS,
&status, &r.CreatedMs, &r.StartedMs); err != nil {
return nil, err
}
r.Status = lobby.RaceStatus(status)
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
for i := range out {
ids, err := s.listDriverIDs(ctx, out[i].ID)
if err != nil {
return nil, err
}
out[i].DriverIDs = ids
}
return out, nil
}
func (s *PgStore) listDriverIDs(ctx context.Context, raceID string) ([]string, error) {
rows, err := s.pool.Query(ctx,
`SELECT driver_id FROM race_drivers WHERE race_id = $1 ORDER BY slot ASC, joined_ms ASC`,
raceID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
// UpsertLobbyDriver mirrors a lobby.DriverMeta row.
func (s *PgStore) UpsertLobbyDriver(ctx context.Context, d lobby.DriverMeta) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO lobby_drivers
(driver_id, name, nickname, avatar_url, clan_id, clan_tag,
status, current_race_id, connected_ms, last_seen_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (driver_id) DO UPDATE SET
name = EXCLUDED.name,
nickname = EXCLUDED.nickname,
avatar_url = EXCLUDED.avatar_url,
clan_id = EXCLUDED.clan_id,
clan_tag = EXCLUDED.clan_tag,
status = EXCLUDED.status,
current_race_id = EXCLUDED.current_race_id,
last_seen_ms = EXCLUDED.last_seen_ms`,
d.ID, d.Name, d.Nickname, d.AvatarURL, d.ClanID, d.ClanTag,
string(d.Status), d.RaceID, d.ConnectedMs, d.LastSeenMs)
if err != nil {
return fmt.Errorf("upsert lobby driver %s: %w", d.ID, err)
}
return nil
}
// DeleteLobbyDriver removes a lobby driver row.
func (s *PgStore) DeleteLobbyDriver(ctx context.Context, driverID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM lobby_drivers WHERE driver_id = $1`, driverID)
return err
}
// ListLobbyDrivers returns all lobby drivers.
func (s *PgStore) ListLobbyDrivers(ctx context.Context) ([]lobby.DriverMeta, error) {
rows, err := s.pool.Query(ctx, `
SELECT driver_id, name, nickname, avatar_url, clan_id, clan_tag,
status, current_race_id, connected_ms, last_seen_ms
FROM lobby_drivers ORDER BY last_seen_ms DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]lobby.DriverMeta, 0)
for rows.Next() {
var d lobby.DriverMeta
var status string
if err := rows.Scan(&d.ID, &d.Name, &d.Nickname, &d.AvatarURL, &d.ClanID, &d.ClanTag,
&status, &d.RaceID, &d.ConnectedMs, &d.LastSeenMs); err != nil {
return nil, err
}
d.Status = lobby.DriverStatus(status)
out = append(out, d)
}
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// finished_races
// ---------------------------------------------------------------------------
// InsertFinished upserts a finished race row.
func (s *PgStore) InsertFinished(ctx context.Context, r FinishedRace) error {
podium := r.Podium
if podium == nil {
podium = []PodiumEntry{}
}
podiumJSON, err := json.Marshal(podium)
if err != nil {
return fmt.Errorf("marshal podium: %w", err)
}
_, err = s.pool.Exec(ctx, `
INSERT INTO races
(id, name, track_id, max_cars, laps, time_limit_s,
driver_ids, status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms,
podium)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
status = EXCLUDED.status,
finished_ms = EXCLUDED.finished_ms,
duration_ms = EXCLUDED.duration_ms,
total_laps = EXCLUDED.total_laps,
total_drivers = EXCLUDED.total_drivers,
winner_driver_id = EXCLUDED.winner_driver_id,
winner_name = EXCLUDED.winner_name,
best_lap_ms = EXCLUDED.best_lap_ms,
podium = EXCLUDED.podium`,
r.ID, r.Name, r.TrackID, r.MaxCars, r.Laps, r.TimeLimitS,
r.DriverIDs, r.Status, r.CreatedMs, r.StartedMs, r.FinishedMs, r.DurationMs,
r.TotalLaps, r.TotalDrivers, r.WinnerDriverID, r.WinnerName, r.BestLapMs,
podiumJSON)
if err != nil {
return fmt.Errorf("insert finished race %s: %w", r.ID, err)
}
return nil
}
// ListFinished returns finished races matching the filter using keyset
// pagination on (finished_ms DESC, id DESC). The cursor's Ms is the
// last finished_ms from the previous page; rows strictly older are
// returned. Tie-breaker on id.
func (s *PgStore) ListFinished(ctx context.Context, trackID string, cur Cursor, limit int) ([]FinishedRace, error) {
if limit <= 0 {
limit = 50
}
args := []any{}
conds := []string{"status IN ('finished','cancelled')"}
if !cur.IsZero() {
args = append(args, cur.Ms, cur.ID)
conds = append(conds, fmt.Sprintf("(finished_ms, id) < ($%d, $%d)", len(args)-1, len(args)))
}
if trackID != "" {
args = append(args, trackID)
conds = append(conds, fmt.Sprintf("track_id = $%d", len(args)))
}
args = append(args, limit+1)
q := `SELECT id, name, track_id, max_cars, laps, time_limit_s,
driver_ids, status, created_ms, started_ms, finished_ms, duration_ms,
total_laps, total_drivers, winner_driver_id, winner_name, best_lap_ms,
podium
FROM races
WHERE ` + joinAnd(conds) + `
ORDER BY finished_ms DESC, id DESC
LIMIT $` + fmt.Sprintf("%d", len(args))
rows, err := s.pool.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list finished: %w", err)
}
defer rows.Close()
return scanFinished(rows)
}
func scanFinished(rows pgx.Rows) ([]FinishedRace, error) {
out := make([]FinishedRace, 0)
for rows.Next() {
var r FinishedRace
var podiumJSON []byte
if err := rows.Scan(
&r.ID, &r.Name, &r.TrackID,
&r.MaxCars, &r.Laps, &r.TimeLimitS, &r.DriverIDs, &r.Status,
&r.CreatedMs, &r.StartedMs, &r.FinishedMs, &r.DurationMs,
&r.TotalLaps, &r.TotalDrivers, &r.WinnerDriverID, &r.WinnerName, &r.BestLapMs,
&podiumJSON,
); err != nil {
return nil, err
}
if len(podiumJSON) > 0 {
if err := json.Unmarshal(podiumJSON, &r.Podium); err != nil {
return nil, fmt.Errorf("unmarshal podium: %w", err)
}
}
if r.Podium == nil {
r.Podium = []PodiumEntry{}
}
out = append(out, r)
}
return out, rows.Err()
}
func joinAnd(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += " AND "
}
out += p
}
return out
}
// ---------------------------------------------------------------------------
// race_plans
// ---------------------------------------------------------------------------
// CreatePlan inserts a new race plan.
func (s *PgStore) CreatePlan(ctx context.Context, p RacePlan) error {
now := time.Now().UnixMilli()
if p.CreatedMs == 0 {
p.CreatedMs = now
}
p.UpdatedMs = now
if p.NextFireMs == 0 {
p.NextFireMs = p.StartAtMs
}
_, err := s.pool.Exec(ctx, `
INSERT INTO race_plans
(id, name, track_id, max_cars, laps, time_limit_s,
start_at_ms, interval_s, count,
enabled, created_ms, updated_ms, next_fire_ms, fires_done)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
p.ID, p.Name, p.TrackID, p.MaxCars, p.Laps, p.TimeLimitS,
p.StartAtMs, p.IntervalS, p.Count,
p.Enabled, p.CreatedMs, p.UpdatedMs, p.NextFireMs, p.FiresDone)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return fmt.Errorf("%w: plan %s", ErrAlreadyExists, p.ID)
}
return fmt.Errorf("insert plan: %w", err)
}
return nil
}
// GetPlan loads a plan by id.
func (s *PgStore) GetPlan(ctx context.Context, id string) (RacePlan, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, name, track_id, max_cars, laps, time_limit_s,
start_at_ms, interval_s, count,
enabled, created_ms, updated_ms, next_fire_ms, fires_done
FROM race_plans WHERE id = $1`, id)
var p RacePlan
if err := row.Scan(
&p.ID, &p.Name, &p.TrackID, &p.MaxCars, &p.Laps, &p.TimeLimitS,
&p.StartAtMs, &p.IntervalS, &p.Count,
&p.Enabled, &p.CreatedMs, &p.UpdatedMs, &p.NextFireMs, &p.FiresDone,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return RacePlan{}, ErrNotFound
}
return RacePlan{}, err
}
return p, nil
}
// ListPlans returns plans in ascending start order, keyset-paginated.
func (s *PgStore) ListPlans(ctx context.Context, cur Cursor, limit int) ([]RacePlan, error) {
if limit <= 0 {
limit = 50
}
args := []any{}
conds := []string{"1=1"}
if !cur.IsZero() {
args = append(args, cur.Ms, cur.ID)
conds = append(conds, fmt.Sprintf("(start_at_ms, id) > ($%d, $%d)", len(args)-1, len(args)))
}
args = append(args, limit+1)
q := `SELECT id, name, track_id, max_cars, laps, time_limit_s,
start_at_ms, interval_s, count,
enabled, created_ms, updated_ms, next_fire_ms, fires_done
FROM race_plans
WHERE ` + joinAnd(conds) + `
ORDER BY start_at_ms ASC, id ASC
LIMIT $` + fmt.Sprintf("%d", len(args))
rows, err := s.pool.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list plans: %w", err)
}
defer rows.Close()
return scanPlans(rows)
}
func scanPlans(rows pgx.Rows) ([]RacePlan, error) {
out := make([]RacePlan, 0)
for rows.Next() {
var p RacePlan
if err := rows.Scan(
&p.ID, &p.Name, &p.TrackID, &p.MaxCars, &p.Laps, &p.TimeLimitS,
&p.StartAtMs, &p.IntervalS, &p.Count,
&p.Enabled, &p.CreatedMs, &p.UpdatedMs, &p.NextFireMs, &p.FiresDone,
); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// DeletePlan removes a plan by id.
func (s *PgStore) DeletePlan(ctx context.Context, id string) error {
tag, err := s.pool.Exec(ctx, `DELETE FROM race_plans WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete plan: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// SetPlanEnabled toggles the enabled flag.
func (s *PgStore) SetPlanEnabled(ctx context.Context, id string, enabled bool) error {
tag, err := s.pool.Exec(ctx,
`UPDATE race_plans SET enabled = $2, updated_ms = $3 WHERE id = $1`,
id, enabled, time.Now().UnixMilli())
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// AdvancePlan moves the next_fire_ms cursor after a successful materialise
// and increments fires_done. If count is reached, disables the plan.
func (s *PgStore) AdvancePlan(ctx context.Context, id string, nextFireMs int64) error {
_, err := s.pool.Exec(ctx, `
UPDATE race_plans
SET next_fire_ms = $2,
fires_done = fires_done + 1,
updated_ms = $3,
enabled = CASE
WHEN count > 0 AND fires_done + 1 >= count THEN FALSE
ELSE enabled
END
WHERE id = $1`,
id, nextFireMs, time.Now().UnixMilli())
return err
}
// DuePlans returns plans whose next_fire_ms is <= now.
func (s *PgStore) DuePlans(ctx context.Context, nowMs int64, limit int) ([]RacePlan, error) {
if limit <= 0 {
limit = 16
}
rows, err := s.pool.Query(ctx, `
SELECT id, name, track_id, max_cars, laps, time_limit_s,
start_at_ms, interval_s, count,
enabled, created_ms, updated_ms, next_fire_ms, fires_done
FROM race_plans
WHERE enabled AND next_fire_ms <= $1
ORDER BY next_fire_ms ASC
LIMIT $2`, nowMs, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPlans(rows)
}
// ---------------------------------------------------------------------------
// queue
// ---------------------------------------------------------------------------
// Enqueue adds (driver, race) to the queue. Idempotent: returns the existing
// entry if present.
func (s *PgStore) Enqueue(ctx context.Context, driverID, raceID, planID string) (QueueEntry, error) {
if driverID == "" || raceID == "" {
return QueueEntry{}, fmt.Errorf("%w: driver_id and race_id required", ErrInvalidInput)
}
now := time.Now().UnixMilli()
_, err := s.pool.Exec(ctx, `
INSERT INTO race_queue (driver_id, race_id, plan_id, enqueued_ms)
VALUES ($1, $2, $3, $4)
ON CONFLICT (driver_id, race_id) DO NOTHING`,
driverID, raceID, planID, now)
if err != nil {
return QueueEntry{}, fmt.Errorf("enqueue: %w", err)
}
row := s.pool.QueryRow(ctx, `
SELECT driver_id, race_id, plan_id, enqueued_ms
FROM race_queue WHERE driver_id = $1 AND race_id = $2`,
driverID, raceID)
var q QueueEntry
if err := row.Scan(&q.DriverID, &q.RaceID, &q.PlanID, &q.EnqueuedMs); err != nil {
return QueueEntry{}, err
}
return q, nil
}
// Dequeue removes (driver, race) entry.
func (s *PgStore) Dequeue(ctx context.Context, driverID, raceID string) error {
tag, err := s.pool.Exec(ctx,
`DELETE FROM race_queue WHERE driver_id = $1 AND race_id = $2`,
driverID, raceID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListQueueByDriver returns queue entries for a driver.
func (s *PgStore) ListQueueByDriver(ctx context.Context, driverID string) ([]QueueEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT driver_id, race_id, plan_id, enqueued_ms
FROM race_queue WHERE driver_id = $1
ORDER BY enqueued_ms ASC`, driverID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]QueueEntry, 0)
for rows.Next() {
var q QueueEntry
if err := rows.Scan(&q.DriverID, &q.RaceID, &q.PlanID, &q.EnqueuedMs); err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
// ListQueueByRace returns all queue entries for a given race, oldest first.
func (s *PgStore) ListQueueByRace(ctx context.Context, raceID string) ([]QueueEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT driver_id, race_id, plan_id, enqueued_ms
FROM race_queue WHERE race_id = $1
ORDER BY enqueued_ms ASC`, raceID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]QueueEntry, 0)
for rows.Next() {
var q QueueEntry
if err := rows.Scan(&q.DriverID, &q.RaceID, &q.PlanID, &q.EnqueuedMs); err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
// CountQueueByRace returns the queue length for a race.
func (s *PgStore) CountQueueByRace(ctx context.Context, raceID string) (int, error) {
var n int
row := s.pool.QueryRow(ctx,
`SELECT COUNT(*) FROM race_queue WHERE race_id = $1`, raceID)
if err := row.Scan(&n); err != nil {
return 0, err
}
return n, nil
}
// ToLobbyMeta converts a finished race to a wire-friendly RaceMeta for
// uniform presentation alongside live races. Podium is dropped here;
// the wire type carries the full PodiumEntry list separately.
func (f FinishedRace) ToLobbyMeta() lobby.RaceMeta {
return lobby.RaceMeta{
ID: f.ID,
Name: f.Name,
TrackID: f.TrackID,
MaxCars: f.MaxCars,
Laps: f.Laps,
TimeLimitS: f.TimeLimitS,
DriverIDs: append([]string(nil), f.DriverIDs...),
Status: lobby.RaceStatus(f.Status),
CreatedMs: f.CreatedMs,
StartedMs: f.StartedMs,
}
}
+118
View File
@@ -0,0 +1,118 @@
// Package races owns the cross-cutting "race list" surface: keyset-paginated
// listing of live and finished races, upcoming (next-N) selection, the
// driver-to-race queue and the recurring race-plan scheduler.
//
// The package composes two storage backends:
//
// - lobby.Service (in-memory): authoritative source for active races
// (status = lobby | countdown | racing). Used directly for the live
// page of /api/races, for the upcoming view and for the scheduler
// materialisation.
// - Postgres (pgxpool): authoritative for finished races (snapshotted
// on SetRaceStatus(finished)), for race plans and for the queue.
// Finished races are read with keyset pagination on
// (finished_ms DESC, id DESC).
package races
import (
"errors"
"fmt"
)
// Errors returned by the service.
var (
ErrNotFound = errors.New("race or plan not found")
ErrInvalidInput = errors.New("invalid input")
ErrAlreadyExists = errors.New("already exists")
ErrQueueDuplicate = errors.New("driver already in queue for this race")
)
// StatusFilter enumerates the high-level status groups the API understands.
type StatusFilter string
const (
StatusAll StatusFilter = "all"
StatusFinished StatusFilter = "finished" // finished only
StatusRacing StatusFilter = "racing" // racing only
StatusLobby StatusFilter = "lobby" // lobby only
)
// ParseStatusFilter accepts a comma-separated list of statuses
// (e.g. "racing,finished") and returns the canonical filters. Empty
// input means StatusAll.
func ParseStatusFilter(s string) ([]StatusFilter, error) {
if s == "" {
return []StatusFilter{StatusAll}, nil
}
out := make([]StatusFilter, 0, 2)
cur := ""
for i := 0; i < len(s); i++ {
c := s[i]
if c == ',' {
tok := statusFilterTrim(cur)
if tok == "" {
cur = ""
continue
}
f, err := parseSingleStatus(tok)
if err != nil {
return nil, err
}
out = append(out, f)
cur = ""
continue
}
cur += string(c)
}
tok := statusFilterTrim(cur)
if tok != "" {
f, err := parseSingleStatus(tok)
if err != nil {
return nil, err
}
out = append(out, f)
}
if len(out) == 0 {
return []StatusFilter{StatusAll}, nil
}
return out, nil
}
func statusFilterTrim(s string) string {
// Trim spaces.
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
s = s[1:]
}
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
s = s[:len(s)-1]
}
return s
}
func parseSingleStatus(s string) (StatusFilter, error) {
switch s {
case "all":
return StatusAll, nil
case "finished":
return StatusFinished, nil
case "racing":
return StatusRacing, nil
case "lobby":
return StatusLobby, nil
default:
return "", fmt.Errorf("%w: unknown status %q", ErrInvalidInput, s)
}
}
// matches returns true if the live lobby status passes the filter.
func (f StatusFilter) matchesLive(liveStatus string) bool {
switch f {
case StatusAll:
return true
case StatusRacing:
return liveStatus == "racing"
case StatusLobby:
return liveStatus == "lobby"
}
return false
}