fix(multiplayer): wire a durable RankedProfileProvider

Same discovery pattern as ResultSubmitter/SessionIssuer, one level
deeper: api.Service.rankedProfile and .profile both only ever read
from an in-memory RankedProfiles map with no durable-store equivalent
at all -- not "adapter exists but unwired" this time, there was no
adapter. Every real request to GET /v1/profile/ranked or /api/v1/profile
always 404'd regardless of a player's actual rating.

Add RankedProfileProvider (an interface, not a struct-literal adapter
this time) and store.PostgresRankedProfiles reading the ratings table;
Service.rankedProfileFor prefers it when set and falls back to the
map otherwise, so every existing test/direct Service literal keeps
compiling and passing unchanged. A missing ratings row maps to the
exact same (zero value, false, nil) the map lookup already produced,
preserving existing not-found semantics rather than reinterpreting
them. LastSeasonID/SeasonHistory are deliberately left unset -- the
ratings table has no season pointer, and reconstructing history needs
its own query and display semantics, not bundled in here speculatively.

Wired into both cmd/control-plane and cmd/testkit-api. Verified
against real PostgreSQL via curl: a fresh identity's ranked profile
correctly 404s through the real adapter (same behavior as before,
now for a real reason instead of an empty map).
This commit is contained in:
Josh Creek
2026-09-01 14:20:04 +01:00
parent 8a9972b6fc
commit a1ae36a54c
4 changed files with 117 additions and 47 deletions
+49 -24
View File
@@ -31,6 +31,9 @@ type ProbeRecorder interface {
RecordProbe(context.Context, string, string, time.Duration, time.Time) error
}
type WorkloadVerifier func(string, time.Time) (domain.WorkloadBinding, error)
type RankedProfileProvider interface {
Get(context.Context, string) (domain.RankedProfile, bool, error)
}
type ResultSubmitter interface {
SubmitResult(context.Context, string, domain.MatchResult, domain.WorkloadBinding, []byte, time.Time) error
}
@@ -95,28 +98,29 @@ type AssignmentView struct {
type AssignmentProvider func(context.Context, string, string, time.Time) (AssignmentView, error)
type Service struct {
Sessions *domain.SessionStore
SessionBackend SessionBackend
SessionIssuer SessionIssuer
SteamLogin SteamLoginProvider
Queue *domain.Queue
Candidate CandidateProvider
CandidateV2 CandidateProviderV2
QueueBackend QueueBackend
CandidateIndex CandidateIndex
Probe ProbeProvider
ProbeRecorder ProbeRecorder
WorkloadVerify WorkloadVerifier
ResultSubmitter ResultSubmitter
ServerRegistrar ServerRegistrar
Assignment AssignmentProvider
Now func() time.Time
Proposals map[string]*domain.Proposal
ProposalBackend ProposalBackend
ProposalPromoter ProposalPromoter
RankedProfiles map[string]domain.RankedProfile
TierPolicy domain.TierPolicy
RateLimiter *RateLimiter
Sessions *domain.SessionStore
SessionBackend SessionBackend
SessionIssuer SessionIssuer
SteamLogin SteamLoginProvider
Queue *domain.Queue
Candidate CandidateProvider
CandidateV2 CandidateProviderV2
QueueBackend QueueBackend
CandidateIndex CandidateIndex
Probe ProbeProvider
ProbeRecorder ProbeRecorder
WorkloadVerify WorkloadVerifier
ResultSubmitter ResultSubmitter
ServerRegistrar ServerRegistrar
Assignment AssignmentProvider
Now func() time.Time
Proposals map[string]*domain.Proposal
ProposalBackend ProposalBackend
ProposalPromoter ProposalPromoter
RankedProfiles map[string]domain.RankedProfile
RankedProfileProvider RankedProfileProvider
TierPolicy domain.TierPolicy
RateLimiter *RateLimiter
// Log receives a credential-safe structured event for lifecycle-relevant
// mutations (currently: server registration and result submission). Nil
// is a valid, silent no-op -- every call site must stay optional so
@@ -127,6 +131,19 @@ type Service struct {
events *eventHub
}
// rankedProfileFor prefers the durable RankedProfileProvider when set,
// falling back to the in-memory RankedProfiles map for existing tests/direct
// Service literals that construct it that way. Both return the same
// (profile, exists) shape either way, so callers don't need to know which
// source answered.
func (s *Service) rankedProfileFor(ctx context.Context, playerID string) (domain.RankedProfile, bool, error) {
if s.RankedProfileProvider != nil {
return s.RankedProfileProvider.Get(ctx, playerID)
}
profile, exists := s.RankedProfiles[playerID]
return profile, exists, nil
}
// logEvent is a nil-safe wrapper so call sites never need their own guard.
func (s *Service) logEvent(event observability.Event) {
if s.Log != nil {
@@ -793,7 +810,11 @@ func (s *Service) profile(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
profile, exists := s.RankedProfiles[playerID]
profile, exists, err := s.rankedProfileFor(r.Context(), playerID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "ranked_profile_unavailable")
return
}
if !exists {
writeError(w, http.StatusNotFound, "not_found")
return
@@ -815,7 +836,11 @@ func (s *Service) rankedProfile(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
profile, exists := s.RankedProfiles[playerID]
profile, exists, err := s.rankedProfileFor(r.Context(), playerID)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "ranked_profile_unavailable")
return
}
if !exists {
writeError(w, http.StatusNotFound, "not_found")
return
+13 -12
View File
@@ -82,18 +82,19 @@ func newAPIHandler(db *sql.DB, indexes ...api.CandidateIndex) http.Handler {
candidateIndex = indexes[0]
}
return (&api.Service{
SessionBackend: store.PostgresSessions{DB: db},
SessionIssuer: store.PostgresSessions{DB: db},
QueueBackend: store.PostgresQueue{DB: db},
ProposalBackend: api.ProposalProviderFromStore(db),
ProposalPromoter: api.ProposalPromoterFromStore(db),
ServerRegistrar: api.ServerRegistrarFromStore(db),
ResultSubmitter: store.PostgresResults{DB: db},
Assignment: api.AssignmentProviderFromStore(db),
CandidateIndex: candidateIndex,
ProbeRecorder: store.PostgresQueue{DB: db},
Now: func() time.Time { return time.Now().UTC() },
Log: logEvent,
SessionBackend: store.PostgresSessions{DB: db},
SessionIssuer: store.PostgresSessions{DB: db},
QueueBackend: store.PostgresQueue{DB: db},
ProposalBackend: api.ProposalProviderFromStore(db),
ProposalPromoter: api.ProposalPromoterFromStore(db),
ServerRegistrar: api.ServerRegistrarFromStore(db),
ResultSubmitter: store.PostgresResults{DB: db},
RankedProfileProvider: store.PostgresRankedProfiles{DB: db},
Assignment: api.AssignmentProviderFromStore(db),
CandidateIndex: candidateIndex,
ProbeRecorder: store.PostgresQueue{DB: db},
Now: func() time.Time { return time.Now().UTC() },
Log: logEvent,
}).Handler()
}
+12 -11
View File
@@ -54,17 +54,18 @@ func main() {
fatalf("apply migrations: %v", err)
}
handler := (&api.Service{
SessionBackend: store.PostgresSessions{DB: db},
SessionIssuer: store.PostgresSessions{DB: db},
SteamLogin: fakeSteamLogin{db: db},
QueueBackend: store.PostgresQueue{DB: db},
ProposalBackend: api.ProposalProviderFromStore(db),
ProposalPromoter: api.ProposalPromoterFromStore(db),
ServerRegistrar: api.ServerRegistrarFromStore(db),
ResultSubmitter: store.PostgresResults{DB: db},
Assignment: api.AssignmentProviderFromStore(db),
ProbeRecorder: store.PostgresQueue{DB: db},
Now: func() time.Time { return time.Now().UTC() },
SessionBackend: store.PostgresSessions{DB: db},
SessionIssuer: store.PostgresSessions{DB: db},
SteamLogin: fakeSteamLogin{db: db},
QueueBackend: store.PostgresQueue{DB: db},
ProposalBackend: api.ProposalProviderFromStore(db),
ProposalPromoter: api.ProposalPromoterFromStore(db),
ServerRegistrar: api.ServerRegistrarFromStore(db),
ResultSubmitter: store.PostgresResults{DB: db},
RankedProfileProvider: store.PostgresRankedProfiles{DB: db},
Assignment: api.AssignmentProviderFromStore(db),
ProbeRecorder: store.PostgresQueue{DB: db},
Now: func() time.Time { return time.Now().UTC() },
}).Handler()
listener, err := net.Listen("tcp", *listen)
if err != nil {
+43
View File
@@ -0,0 +1,43 @@
package store
import (
"context"
"database/sql"
"fmt"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
const RankedProfileSelectSQL = `SELECT rating, deviation, volatility, ranked_games, updated_at
FROM ratings
WHERE player_id = $1`
// PostgresRankedProfiles reads the durable rating row api.Service's
// RankedProfileProvider needs. A missing row means "this player has no
// ranked profile yet" (never queued ranked, or their identity predates any
// result) -- that's a real, expected state, not an error, and is reported
// the same way the in-memory RankedProfiles map api.Service still falls
// back to already did: (zero value, false, nil).
//
// LastSeasonID and SeasonHistory are deliberately left at their zero values.
// The ratings table has no "current season" column, and reconstructing
// season history means a second query against ranked_season_rollovers with
// its own display semantics to settle -- a real, separate piece of work,
// not bundled into this read path speculatively.
type PostgresRankedProfiles struct{ DB *sql.DB }
func (p PostgresRankedProfiles) Get(ctx context.Context, playerID string) (domain.RankedProfile, bool, error) {
if p.DB == nil || playerID == "" {
return domain.RankedProfile{}, false, fmt.Errorf("invalid ranked profile lookup")
}
var profile domain.RankedProfile
err := p.DB.QueryRowContext(ctx, RankedProfileSelectSQL, playerID).
Scan(&profile.Value, &profile.RD, &profile.Volatility, &profile.RankedGames, &profile.LastRatedAt)
if err == sql.ErrNoRows {
return domain.RankedProfile{}, false, nil
}
if err != nil {
return domain.RankedProfile{}, false, err
}
return profile, true, nil
}