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 RecordProbe(context.Context, string, string, time.Duration, time.Time) error
} }
type WorkloadVerifier func(string, time.Time) (domain.WorkloadBinding, error) type WorkloadVerifier func(string, time.Time) (domain.WorkloadBinding, error)
type RankedProfileProvider interface {
Get(context.Context, string) (domain.RankedProfile, bool, error)
}
type ResultSubmitter interface { type ResultSubmitter interface {
SubmitResult(context.Context, string, domain.MatchResult, domain.WorkloadBinding, []byte, time.Time) error 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 AssignmentProvider func(context.Context, string, string, time.Time) (AssignmentView, error)
type Service struct { type Service struct {
Sessions *domain.SessionStore Sessions *domain.SessionStore
SessionBackend SessionBackend SessionBackend SessionBackend
SessionIssuer SessionIssuer SessionIssuer SessionIssuer
SteamLogin SteamLoginProvider SteamLogin SteamLoginProvider
Queue *domain.Queue Queue *domain.Queue
Candidate CandidateProvider Candidate CandidateProvider
CandidateV2 CandidateProviderV2 CandidateV2 CandidateProviderV2
QueueBackend QueueBackend QueueBackend QueueBackend
CandidateIndex CandidateIndex CandidateIndex CandidateIndex
Probe ProbeProvider Probe ProbeProvider
ProbeRecorder ProbeRecorder ProbeRecorder ProbeRecorder
WorkloadVerify WorkloadVerifier WorkloadVerify WorkloadVerifier
ResultSubmitter ResultSubmitter ResultSubmitter ResultSubmitter
ServerRegistrar ServerRegistrar ServerRegistrar ServerRegistrar
Assignment AssignmentProvider Assignment AssignmentProvider
Now func() time.Time Now func() time.Time
Proposals map[string]*domain.Proposal Proposals map[string]*domain.Proposal
ProposalBackend ProposalBackend ProposalBackend ProposalBackend
ProposalPromoter ProposalPromoter ProposalPromoter ProposalPromoter
RankedProfiles map[string]domain.RankedProfile RankedProfiles map[string]domain.RankedProfile
TierPolicy domain.TierPolicy RankedProfileProvider RankedProfileProvider
RateLimiter *RateLimiter TierPolicy domain.TierPolicy
RateLimiter *RateLimiter
// Log receives a credential-safe structured event for lifecycle-relevant // Log receives a credential-safe structured event for lifecycle-relevant
// mutations (currently: server registration and result submission). Nil // mutations (currently: server registration and result submission). Nil
// is a valid, silent no-op -- every call site must stay optional so // is a valid, silent no-op -- every call site must stay optional so
@@ -127,6 +131,19 @@ type Service struct {
events *eventHub 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. // logEvent is a nil-safe wrapper so call sites never need their own guard.
func (s *Service) logEvent(event observability.Event) { func (s *Service) logEvent(event observability.Event) {
if s.Log != nil { if s.Log != nil {
@@ -793,7 +810,11 @@ func (s *Service) profile(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return 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 { if !exists {
writeError(w, http.StatusNotFound, "not_found") writeError(w, http.StatusNotFound, "not_found")
return return
@@ -815,7 +836,11 @@ func (s *Service) rankedProfile(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return 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 { if !exists {
writeError(w, http.StatusNotFound, "not_found") writeError(w, http.StatusNotFound, "not_found")
return return
+13 -12
View File
@@ -82,18 +82,19 @@ func newAPIHandler(db *sql.DB, indexes ...api.CandidateIndex) http.Handler {
candidateIndex = indexes[0] candidateIndex = indexes[0]
} }
return (&api.Service{ return (&api.Service{
SessionBackend: store.PostgresSessions{DB: db}, SessionBackend: store.PostgresSessions{DB: db},
SessionIssuer: store.PostgresSessions{DB: db}, SessionIssuer: store.PostgresSessions{DB: db},
QueueBackend: store.PostgresQueue{DB: db}, QueueBackend: store.PostgresQueue{DB: db},
ProposalBackend: api.ProposalProviderFromStore(db), ProposalBackend: api.ProposalProviderFromStore(db),
ProposalPromoter: api.ProposalPromoterFromStore(db), ProposalPromoter: api.ProposalPromoterFromStore(db),
ServerRegistrar: api.ServerRegistrarFromStore(db), ServerRegistrar: api.ServerRegistrarFromStore(db),
ResultSubmitter: store.PostgresResults{DB: db}, ResultSubmitter: store.PostgresResults{DB: db},
Assignment: api.AssignmentProviderFromStore(db), RankedProfileProvider: store.PostgresRankedProfiles{DB: db},
CandidateIndex: candidateIndex, Assignment: api.AssignmentProviderFromStore(db),
ProbeRecorder: store.PostgresQueue{DB: db}, CandidateIndex: candidateIndex,
Now: func() time.Time { return time.Now().UTC() }, ProbeRecorder: store.PostgresQueue{DB: db},
Log: logEvent, Now: func() time.Time { return time.Now().UTC() },
Log: logEvent,
}).Handler() }).Handler()
} }
+12 -11
View File
@@ -54,17 +54,18 @@ func main() {
fatalf("apply migrations: %v", err) fatalf("apply migrations: %v", err)
} }
handler := (&api.Service{ handler := (&api.Service{
SessionBackend: store.PostgresSessions{DB: db}, SessionBackend: store.PostgresSessions{DB: db},
SessionIssuer: store.PostgresSessions{DB: db}, SessionIssuer: store.PostgresSessions{DB: db},
SteamLogin: fakeSteamLogin{db: db}, SteamLogin: fakeSteamLogin{db: db},
QueueBackend: store.PostgresQueue{DB: db}, QueueBackend: store.PostgresQueue{DB: db},
ProposalBackend: api.ProposalProviderFromStore(db), ProposalBackend: api.ProposalProviderFromStore(db),
ProposalPromoter: api.ProposalPromoterFromStore(db), ProposalPromoter: api.ProposalPromoterFromStore(db),
ServerRegistrar: api.ServerRegistrarFromStore(db), ServerRegistrar: api.ServerRegistrarFromStore(db),
ResultSubmitter: store.PostgresResults{DB: db}, ResultSubmitter: store.PostgresResults{DB: db},
Assignment: api.AssignmentProviderFromStore(db), RankedProfileProvider: store.PostgresRankedProfiles{DB: db},
ProbeRecorder: store.PostgresQueue{DB: db}, Assignment: api.AssignmentProviderFromStore(db),
Now: func() time.Time { return time.Now().UTC() }, ProbeRecorder: store.PostgresQueue{DB: db},
Now: func() time.Time { return time.Now().UTC() },
}).Handler() }).Handler()
listener, err := net.Listen("tcp", *listen) listener, err := net.Listen("tcp", *listen)
if err != nil { 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
}