feat: atomically complete match results

This commit is contained in:
Josh Creek
2026-08-31 21:46:32 +01:00
parent 7b6e22292a
commit 307828ff7f
4 changed files with 79 additions and 4 deletions
+3 -2
View File
@@ -54,8 +54,9 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md).
- [ ] **IN PROGRESS:** Authenticate results with pod/GameServer-bound workload - [ ] **IN PROGRESS:** Authenticate results with pod/GameServer-bound workload
identity; make identical duplicates idempotent and conflicting results identity; make identical duplicates idempotent and conflicting results
inert/alerting. Pure Go credential-claim validation, binding, hashing, inert/alerting. Pure Go credential-claim validation, binding, hashing,
reconciliation, and SQL boundaries exist; projected-token/JWT adapters, reconciliation, and the atomic receipt/completion/outbox SQL boundary exist;
trusted-cluster verification, and production alerting remain. projected-token/JWT adapters, trusted-cluster verification, rating-lock
integration, and production alerting remain.
- [x] Complete the threat model for forgery, replay, queue/flood/bot abuse, - [x] Complete the threat model for forgery, replay, queue/flood/bot abuse,
workload/insider compromise, DDoS, supply chain and denial-of-wallet workload/insider compromise, DDoS, supply chain and denial-of-wallet
([THREAT-MODEL.md](docs/THREAT-MODEL.md)). ([THREAT-MODEL.md](docs/THREAT-MODEL.md)).
+1 -1
View File
@@ -1202,7 +1202,7 @@ the local/CI/community transport, not a silent production fallback.
| 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional, derives tiers only through validated backend-owned rating bands, and keeps casual ratings outside the API; authenticated HTTP now returns the authoritative ranked view | `server/domain/rating.go`, `tier_test.go` and `server/api/service.go` cover provisional override, exact band boundaries, malformed policy rejection, session authentication and ranked-only response fields; persisted tier policy, client UI and reconnect transport remain | | 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional, derives tiers only through validated backend-owned rating bands, and keeps casual ratings outside the API; authenticated HTTP now returns the authoritative ranked view | `server/domain/rating.go`, `tier_test.go` and `server/api/service.go` cover provisional override, exact band boundaries, malformed policy rejection, session authentication and ranked-only response fields; persisted tier policy, client UI and reconnect transport remain |
| 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season policy compresses 25% toward 1500, clamps RD to 200350, preserves volatility/history, is idempotent by season ID, and defines exact 12-week windows/due detection; migration and Go store now persist a per-player/per-season marker and rating update atomically | `server/domain/rating.go`, `season_test.go`, `server/migrations/0001_initial.sql` and `server/store/season_sql.go` cover compression, floor/cap, duplicate replay, window boundary, completed-season idempotence, row locking and conflict-safe rollover markers; live PostgreSQL execution and maintenance scheduler remain | | 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season policy compresses 25% toward 1500, clamps RD to 200350, preserves volatility/history, is idempotent by season ID, and defines exact 12-week windows/due detection; migration and Go store now persist a per-player/per-season marker and rating update atomically | `server/domain/rating.go`, `season_test.go`, `server/migrations/0001_initial.sql` and `server/store/season_sql.go` cover compression, floor/cap, duplicate replay, window boundary, completed-season idempotence, row locking and conflict-safe rollover markers; live PostgreSQL execution and maintenance scheduler remain |
| 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Pure Go ranked connection policy binds match/server/player/team/slot/protocol, supports 60 s reclaim with server-owned generations, fences old connections, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder; canonical signed authorisation issuance/verification now gates admission | `server/domain/reconnect.go`, `join_auth.go` and adversarial fixtures cover repeated backend-independent reclaim, all signed claim binding, tampering, failed verification, old-generation fencing, grace boundary and deterministic cooldown audit ordering; persistent lease fencing, join transport and full match/result integration remain | | 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Pure Go ranked connection policy binds match/server/player/team/slot/protocol, supports 60 s reclaim with server-owned generations, fences old connections, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder; canonical signed authorisation issuance/verification now gates admission | `server/domain/reconnect.go`, `join_auth.go` and adversarial fixtures cover repeated backend-independent reclaim, all signed claim binding, tampering, failed verification, old-generation fencing, grace boundary and deterministic cooldown audit ordering; persistent lease fencing, join transport and full match/result integration remain |
| 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Pure Go result policy binds match/server/workload identity, hashes canonical payloads, makes identical retries idempotent, leaves conflicts inert, separates integrity eligibility, classifies roster/simulation/result/fairness evidence, validates annotation signatures/digests, and exposes 5 m alert/30 m review delivery thresholds; Go store SQL defines conflict-safe receipt insert, deterministic match/rating locks and atomic completion/outbox boundaries | `server/domain/result.go`, `server/domain/workload.go` and `server/store/result_sql.go` plus adversarial fixtures cover credential binding, duplicate/conflict, annotation forgery, delivery-outage-versus-integrity classification, commit and lock ordering; production credential verification, Agones annotation persistence/reconciliation, live PostgreSQL execution and integrity evidence adapters remain | | 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Pure Go result policy binds match/server/workload identity, hashes canonical payloads, makes identical retries idempotent, leaves conflicts inert, separates integrity eligibility, classifies roster/simulation/result/fairness evidence, validates annotation signatures/digests, and exposes 5 m alert/30 m review delivery thresholds; Go store SQL now executes the receipt → match lock → completion → receipt acknowledgment → outbox boundary atomically | `server/domain/result.go`, `workload.go` and `server/store/result_sql.go` plus adversarial fixtures cover credential binding, duplicate/conflict, annotation forgery, delivery-outage-versus-integrity classification, commit ordering and idempotent SQL reconciliation; production credential verification, Agones annotation persistence/reconciliation, rating-lock integration, live PostgreSQL execution and integrity evidence adapters remain |
#### 8D — Agones, allocation and regional scaling #### 8D — Agones, allocation and regional scaling
+74 -1
View File
@@ -1,5 +1,15 @@
package store package store
import (
"bytes"
"context"
"database/sql"
"fmt"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
)
// ResultReceiptInsertSQL intentionally uses DO NOTHING. The adapter must // ResultReceiptInsertSQL intentionally uses DO NOTHING. The adapter must
// select the existing receipt afterward and compare its digest; an identical // select the existing receipt afterward and compare its digest; an identical
// retry is acknowledged, while a different payload is a conflict with no // retry is acknowledged, while a different payload is a conflict with no
@@ -18,7 +28,7 @@ FOR UPDATE`
// ResultCommitLockSQL establishes the match lock before participant/rating // ResultCommitLockSQL establishes the match lock before participant/rating
// locks. Rating rows are then locked in lexical player-ID order by the // locks. Rating rows are then locked in lexical player-ID order by the
// adapter, ensuring every concurrent result computes from one snapshot. // adapter, ensuring every concurrent result computes from one snapshot.
const ResultCommitLockSQL = `SELECT match_id, playlist, state const ResultCommitLockSQL = `SELECT match_id, playlist, state, revision
FROM matches FROM matches
WHERE match_id = $1 AND server_id = $2 WHERE match_id = $1 AND server_id = $2
FOR UPDATE` FOR UPDATE`
@@ -27,6 +37,10 @@ const ResultMatchCompleteSQL = `UPDATE matches
SET state = 'COMPLETED', revision = revision + 1, completed_at = $2 SET state = 'COMPLETED', revision = revision + 1, completed_at = $2
WHERE match_id = $1 AND state = 'RESULT_PENDING'` WHERE match_id = $1 AND state = 'RESULT_PENDING'`
const ResultReceiptCommitSQL = `UPDATE result_receipts
SET committed_at = COALESCE(committed_at, $2)
WHERE match_id = $1`
const ResultOutboxSQL = `INSERT INTO outbox const ResultOutboxSQL = `INSERT INTO outbox
(event_id, aggregate_type, aggregate_id, revision, event_type, payload) (event_id, aggregate_type, aggregate_id, revision, event_type, payload)
VALUES ($1, 'match', $2, $3, 'match_completed', $4)` VALUES ($1, 'match', $2, $3, 'match_completed', $4)`
@@ -36,3 +50,62 @@ FROM ratings
WHERE player_id = ANY($1) WHERE player_id = ANY($1)
ORDER BY player_id ORDER BY player_id
FOR UPDATE` FOR UPDATE`
// CompleteResult is the durable receipt/reconciliation boundary. The caller
// must have already authenticated the workload and computed the receipt
// digest. Duplicate identical receipts continue the same completion path;
// conflicting payloads fail without mutating the existing receipt.
func CompleteResult(ctx context.Context, db *sql.DB, receipt domain.ResultReceipt, serverID, eventID string, payload []byte, now time.Time) error {
if receipt.ResultID == "" || receipt.MatchID == "" || serverID == "" || eventID == "" || len(payload) == 0 {
return fmt.Errorf("invalid result transaction arguments")
}
return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
result, err := tx.ExecContext(ctx, ResultReceiptInsertSQL, receipt.ResultID, receipt.MatchID, receipt.ResultNonce, receipt.PayloadDigest[:], string(receipt.IntegrityState), receipt.ReceivedAt)
if err != nil {
return err
}
inserted, err := result.RowsAffected()
if err != nil {
return err
}
if inserted == 0 {
var priorID, priorMatch, priorNonce, priorIntegrity string
var priorDigest []byte
var receivedAt, committedAt time.Time
if err := tx.QueryRowContext(ctx, ResultReceiptSelectSQL, receipt.MatchID).Scan(&priorID, &priorMatch, &priorNonce, &priorDigest, &priorIntegrity, &receivedAt, &committedAt); err != nil {
return fmt.Errorf("result receipt conflict: %w", err)
}
if priorID != receipt.ResultID || priorMatch != receipt.MatchID || priorNonce != receipt.ResultNonce || priorIntegrity != string(receipt.IntegrityState) || !bytes.Equal(priorDigest, receipt.PayloadDigest[:]) {
return fmt.Errorf("conflicting result receipt")
}
}
var lockedMatch, playlist, state string
var revision uint64
if err := tx.QueryRowContext(ctx, ResultCommitLockSQL, receipt.MatchID, serverID).Scan(&lockedMatch, &playlist, &state, &revision); err != nil {
return err
}
if state == "COMPLETED" {
_, err := tx.ExecContext(ctx, ResultReceiptCommitSQL, receipt.MatchID, now)
return err
}
if state != "RESULT_PENDING" {
return fmt.Errorf("match is not result-pending: %s", state)
}
updated, err := tx.ExecContext(ctx, ResultMatchCompleteSQL, receipt.MatchID, now)
if err != nil {
return err
}
changed, err := updated.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return fmt.Errorf("result completion lost race")
}
if _, err := tx.ExecContext(ctx, ResultReceiptCommitSQL, receipt.MatchID, now); err != nil {
return err
}
_, err = tx.ExecContext(ctx, ResultOutboxSQL, eventID, receipt.MatchID, revision+1, payload)
return err
})
}
+1
View File
@@ -8,6 +8,7 @@ func TestResultSQLPreservesReceiptConflictAndAtomicCommitBoundaries(t *testing.T
ResultReceiptSelectSQL: {"FOR UPDATE", "committed_at"}, ResultReceiptSelectSQL: {"FOR UPDATE", "committed_at"},
ResultCommitLockSQL: {"server_id = $2", "FOR UPDATE"}, ResultCommitLockSQL: {"server_id = $2", "FOR UPDATE"},
ResultMatchCompleteSQL: {"state = 'RESULT_PENDING'", "revision = revision + 1"}, ResultMatchCompleteSQL: {"state = 'RESULT_PENDING'", "revision = revision + 1"},
ResultReceiptCommitSQL: {"COALESCE(committed_at", "committed_at"},
ResultOutboxSQL: {"match_completed", "aggregate_id", "revision"}, ResultOutboxSQL: {"match_completed", "aggregate_id", "revision"},
RatingLockSQL: {"ORDER BY player_id", "FOR UPDATE"}, RatingLockSQL: {"ORDER BY player_id", "FOR UPDATE"},
} }