fix(matchmaking): make regional RTT evidence obtainable end to end

domain.validCandidate hard-requires a non-empty PredictedRTT map, but
CreateQueueTicket persisted an empty one and the only endpoint that
could fill it returned 503 in every real binary, because Service.Probe
was assigned nowhere outside api tests. No client-created ticket could
ever be selected by the matcher. The Godot client had no probe method at
all, so even a wired backend was unreachable from the game.

Four distinct defects had to be fixed for this path to work:

Nothing issued the nonce ProbeProvider was meant to compare against, so
the contract could not be satisfied even in principle. Add
POST /v1/probes/{region}/challenge, backed by a durable single-use
challenge -- durable because any replica may serve the answer for a
challenge another replica issued. RTT is the interval between issuing
and receiving, so no client-reported latency reaches placement.

CreateQueueTicket marshalled a nil map to JSON `null`, a JSONB scalar
rather than an object, and jsonb_set rejects that with "cannot set path
in scalar". RecordProbe would have failed at runtime even once wired.
Persist an object, and normalise non-object values in the update for
rows already written.

A nil ProbeRecorder made the handler report success while persisting
nothing, which silently leaves the ticket unmatchable. That is a
misconfiguration, not a successful probe; it now returns 503.

A successful probe updated PostgreSQL only. The candidate inserted at
enqueue time carries an empty RTT map, and the Redis keyspace has its
TTL continually refreshed, so the stale entry need never repair itself.
Refresh that player's projection after the probe commits.

Client side: add the challenge/answer round trip and have the
matchmaking screen collect evidence before creating a ticket, since
queueing first produces a search that can never match. Probing every
region fully is not required -- placement uses whichever regions
answered -- but queueing with none is refused rather than silently
stalling.

New integration test drives the real enqueue and probe paths and then
asks the actual matcher predicate, rather than hand-building a candidate
the way the unit tests do -- which is exactly why they missed this.

Also make the integration schema reset drop the whole public schema: the
enumerated table list silently broke with each new migration.
This commit is contained in:
Josh Creek
2026-09-05 10:49:28 +01:00
parent 5765532409
commit 801fca7cb0
16 changed files with 1729 additions and 71 deletions
+49 -1
View File
@@ -194,6 +194,14 @@ func CreateQueueTicket(ctx context.Context, db *sql.DB, ticketID, playerID, idem
}
return fmt.Errorf("%w until %s", domain.ErrPlayerCooldown, cooldownEndsAt.UTC().Format(time.RFC3339))
}
// A nil map marshals to JSON `null`, a JSONB scalar -- not an empty
// object. jsonb_set then fails with "cannot set path in scalar", so
// the first probe for this player could never be recorded even once
// the probe endpoint was wired. Persist an object from the start.
if candidate.PredictedRTT == nil {
candidate.PredictedRTT = map[string]float64{}
ticket.Candidate.PredictedRTT = candidate.PredictedRTT
}
predictedRTT, err := json.Marshal(candidate.PredictedRTT)
if err != nil {
return err
@@ -226,7 +234,13 @@ func (q PostgresQueue) RecordProviderAllocation(ctx context.Context, allocation
}
const QueueProbeRecordSQL = `UPDATE queue_tickets
SET predicted_rtt = jsonb_set(COALESCE(predicted_rtt, '{}'::jsonb), ARRAY[$2], to_jsonb($3::double precision), true)
-- COALESCE only guards SQL NULL. Rows written before the insert fix hold a
-- JSONB scalar null, which jsonb_set rejects outright, so normalise anything
-- that is not an object before setting the region key.
SET predicted_rtt = jsonb_set(
CASE WHEN jsonb_typeof(COALESCE(predicted_rtt, '{}'::jsonb)) = 'object'
THEN predicted_rtt ELSE '{}'::jsonb END,
ARRAY[$2], to_jsonb($3::double precision), true)
WHERE player_id = $1 AND state IN ('QUEUED', 'PROPOSED') AND expires_at > $4`
func (q PostgresQueue) RecordProbe(ctx context.Context, playerID, region string, rtt time.Duration, now time.Time) error {
@@ -373,3 +387,37 @@ func queueTicketRecordToDomain(record queueTicketRecord) domain.QueueTicket {
candidate := domain.Candidate{TicketID: record.TicketID, PlayerID: record.PlayerID, Playlist: domain.Playlist(record.Playlist), ClientBuild: record.ClientBuild, ProtocolVersion: record.ProtocolVersion, EnqueuedAt: record.EnqueuedAt, PredictedRTT: record.PredictedRTT}
return domain.QueueTicket{TicketID: record.TicketID, PlayerID: record.PlayerID, ProposalID: record.ProposalID, MatchID: record.MatchID, Candidate: candidate, Playlist: domain.Playlist(record.Playlist), State: domain.State(record.State), Revision: record.Revision, EnqueuedAt: record.EnqueuedAt, ExpiresAt: record.ExpiresAt}
}
// QueueCandidateByPlayerSQL mirrors QueueCandidateProjectionSQL for a single
// player, so a probe can repair that player's transient index entry without
// re-reading the whole queue.
const QueueCandidateByPlayerSQL = `SELECT q.ticket_id, q.player_id, q.playlist, q.client_build,
q.protocol_version, q.enqueued_at, q.predicted_rtt, COALESCE(r.rating, $3)
FROM queue_tickets q
LEFT JOIN ratings r ON r.player_id = q.player_id
WHERE q.player_id = $1 AND q.state = 'QUEUED' AND q.expires_at > $2`
// FindQueuedCandidateByPlayer returns the player's live queue candidate, if
// any. The second result reports whether the player is currently queued; a
// player who is not queued is not an error.
func FindQueuedCandidateByPlayer(ctx context.Context, db *sql.DB, playerID string, now time.Time) (domain.Candidate, bool, error) {
if db == nil || playerID == "" || now.IsZero() {
return domain.Candidate{}, false, fmt.Errorf("invalid queued candidate lookup")
}
var candidate domain.Candidate
var playlist string
var predictedRTT []byte
err := db.QueryRowContext(ctx, QueueCandidateByPlayerSQL, playerID, now, domain.GlickoInitialRating).
Scan(&candidate.TicketID, &candidate.PlayerID, &playlist, &candidate.ClientBuild, &candidate.ProtocolVersion, &candidate.EnqueuedAt, &predictedRTT, &candidate.Rating)
if err == sql.ErrNoRows {
return domain.Candidate{}, false, nil
}
if err != nil {
return domain.Candidate{}, false, err
}
if err := json.Unmarshal(predictedRTT, &candidate.PredictedRTT); err != nil {
return domain.Candidate{}, false, fmt.Errorf("decode candidate RTT: %w", err)
}
candidate.Playlist = domain.Playlist(playlist)
return candidate, true, nil
}