Files
Josh Creek 801fca7cb0 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.
2026-09-05 10:49:28 +01:00

101 lines
3.7 KiB
Go

package store
import (
"context"
"crypto/rand"
"database/sql"
"fmt"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
// ProbeNonceBytes is the challenge size. It only needs to be unguessable
// within the freshness window, not long-lived key material.
const ProbeNonceBytes = 16
const (
ProbeChallengeUpsertSQL = `INSERT INTO probe_challenges (player_id, region, nonce, issued_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (player_id, region) DO UPDATE
SET nonce = EXCLUDED.nonce, issued_at = EXCLUDED.issued_at`
// Consuming deletes in the same statement: a challenge is single-use, so a
// captured probe response cannot be replayed to refresh a stale RTT.
ProbeChallengeConsumeSQL = `DELETE FROM probe_challenges
WHERE player_id = $1 AND region = $2
RETURNING nonce, issued_at`
ProbeChallengePurgeSQL = `DELETE FROM probe_challenges WHERE issued_at < $1`
)
// IssueProbeChallenge mints and stores a fresh nonce for one player and
// region. It is durable rather than per-process because any control-plane
// replica may serve the follow-up submission.
func IssueProbeChallenge(ctx context.Context, db *sql.DB, playerID, region string, now time.Time) ([]byte, error) {
if db == nil || playerID == "" || (region != "EU" && region != "NA") || now.IsZero() {
return nil, fmt.Errorf("invalid probe challenge arguments")
}
nonce := make([]byte, ProbeNonceBytes)
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
if _, err := db.ExecContext(ctx, ProbeChallengeUpsertSQL, playerID, region, nonce, now); err != nil {
return nil, err
}
return nonce, nil
}
// ConsumeProbeChallenge returns the outstanding nonce and when it was issued,
// removing it so it cannot be reused.
func ConsumeProbeChallenge(ctx context.Context, db *sql.DB, playerID, region string) ([]byte, time.Time, error) {
if db == nil || playerID == "" || (region != "EU" && region != "NA") {
return nil, time.Time{}, fmt.Errorf("invalid probe challenge arguments")
}
var nonce []byte
var issuedAt time.Time
err := db.QueryRowContext(ctx, ProbeChallengeConsumeSQL, playerID, region).Scan(&nonce, &issuedAt)
if err == sql.ErrNoRows {
return nil, time.Time{}, domain.ErrInvalidProbe
}
if err != nil {
return nil, time.Time{}, err
}
return nonce, issuedAt, nil
}
// PurgeExpiredProbeChallenges drops challenges that can no longer be answered
// within the freshness window, so an abandoned probe cannot accumulate.
func PurgeExpiredProbeChallenges(ctx context.Context, db *sql.DB, now time.Time) (int64, error) {
if db == nil || now.IsZero() {
return 0, fmt.Errorf("invalid probe challenge purge arguments")
}
result, err := db.ExecContext(ctx, ProbeChallengePurgeSQL, now.Add(-domain.ProbeFreshness))
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// ProbeEvidenceFromChallenge is the production ProbeProvider. The RTT is
// derived entirely from backend timestamps -- the interval between issuing the
// challenge and receiving the answer -- so no client-reported latency
// influences placement, which is the property docs/MATCHMAKING.md §4 requires.
func ProbeEvidenceFromChallenge(ctx context.Context, db *sql.DB, playerID, region string, opaqueLocation, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error) {
expectedNonce, issuedAt, err := ConsumeProbeChallenge(ctx, db, playerID, region)
if err != nil {
return domain.ProbeEvidence{}, nil, err
}
rtt := receivedAt.Sub(issuedAt)
if rtt < 0 {
// Clock skew between replicas; treat as immediate rather than letting
// a negative duration through to placement.
rtt = 0
}
return domain.ProbeEvidence{
OpaqueLocation: opaqueLocation,
Nonce: nonce,
IssuedAt: issuedAt,
Region: region,
ServerRTT: rtt,
}, expectedNonce, nil
}