Commit Graph

123 Commits

Author SHA1 Message Date
Josh Creek 4248e51c60 fix(server): enforce durable identity bans on session issuance and auth
banned_until and ban_reason have been in the schema since 0001, but no
production query ever read them -- grepping the tree found no reference
outside the migration itself. The only ban check was an in-memory map on
domain.TicketVerifier used by domain tests. Once real Steam login is
wired, a banned identity would keep full access through every existing
session until expiry and could obtain new ones.

Make the ban part of the durable authentication transaction rather than
a policy each login adapter must remember to re-implement:

- Session issuance inserts only when the identity exists and has no
  active ban, so a banned player cannot mint a session.
- Authentication joins the identity and rejects an active ban on every
  request, so a ban takes effect immediately on every replica rather
  than at session expiry.
- ApplyIdentityBan sets the ban and revokes that identity's sessions in
  one serializable transaction, closing the window where the ban is
  durable but another replica still accepts an issued session.

Bans are time-bounded and clearing one does not resurrect sessions the
ban revoked.

Tests cover enforcement across two independently constructed stores
standing in for two replicas, expiry/unban semantics, and -- separately,
because revocation would otherwise mask it -- that a ban applied without
revoking anything still blocks the next request.
2026-09-05 10:26:25 +01:00
Josh Creek 320ec46ba2 fix(server): partition and bound the Redis candidate projection
Both playlists shared one hash and sorted set, causing two independent
failures.

Starvation: Snapshot performed an unbounded ZRANGEBYSCORE and HMGET,
decoded the whole queue, and the matcher then truncated to its candidate
limit *before* filtering by playlist. A large casual prefix could
therefore leave the ranked worker with zero candidates indefinitely even
while ranked tickets were queued further down the set.

Mutual erasure: each matcher captured only its own playlist as the
durable source, but Rebuild replaced the shared keys, so a casual repair
wiped ranked projections and vice versa.

Namespace the keys per playlist, push the limit into Redis (LIMIT 0 N)
so reads no longer scale with total queue depth, and scope Rebuild to
one namespace. Rebuild now rejects a candidate whose playlist does not
match the namespace, which would reintroduce the starvation. Upsert
derives the namespace from the candidate; Remove takes the playlist,
since a ticket ID alone no longer identifies its namespace.

Add tests for a 300-deep casual backlog not starving ranked, for neither
playlist's rebuild erasing the other, and for the limit being applied
without losing enqueue ordering.
2026-09-05 10:23:52 +01:00
Josh Creek f6a87463c5 fix(server): load authoritative ratings into ranked candidates
The candidate projection selected only from queue_tickets and its scan
never set Candidate.Rating, so every PostgreSQL-sourced ranked candidate
arrived with Go's zero value. Rating tolerance, selection scoring and
team partitioning all read that field, so ranked matchmaking treated a
900-rated player as identical to a 2100-rated one. Unit tests missed it
because they construct candidates with ratings already populated.

Join the ratings table, defaulting to domain.GlickoInitialRating for a
player with no ratings row yet -- a genuinely new profile, matching the
column default.

Fix the same defect on the Redis path too, which is reached differently:
the projection is seeded from the candidate CreateQueueTicket builds,
not from the candidate query, and that candidate also left Rating unset.
Resolve the rating inside the enqueue transaction so both projections
agree on one authoritative value. The rating is never client-supplied.

Add a store-backed test with deliberately distant ratings (900 vs 2100)
plus an unrated player, asserting both projections and that the spread
survives. Verified it fails without the fix.
2026-09-05 10:19:23 +01:00
Josh Creek 1dd05c75f1 fix(server): repair the initial-connect outbox envelope and unblock dispatch
ApplyInitialConnectPlan wrote a payload of {match_id,state,action},
omitting event, revision, resource_id, occurred_at and player_ids --
every field deliverStateOutboxEvent requires. Delivery rejected the row,
dispatch returned on the first error so it was never acknowledged, and
because reads are ordered oldest-first it was retried ahead of every
later state_changed event on every 100ms poll. One initial-connect
transition therefore blocked lifecycle delivery for all matches, not
just its own.

Two independent fixes, since either alone leaves the system fragile:

Build envelopes through one validating helper (MarshalOutboxEnvelope)
and convert all five writers to it. A writer that omits a required
field now fails its own transaction instead of committing a row that
can only ever poison the queue. The helper takes revision as int64 so
the -1 "nothing matched" sentinel some CTEs return surfaces as an error
rather than wrapping to a huge uint64.

Make dispatch resilient regardless: a delivery failure is now counted
against that row and the batch continues, with the row dead-lettered
after MaxOutboxDeliveryAttempts so a poison event degrades to one lost
notification instead of a stalled queue. Ordering within an aggregate
is still honoured -- later events of a failed match are deferred, so no
client observes that match's newer state before its older state. An ack
failure still stops the batch, being a database rather than a payload
problem.

Initial-connect events now address every participant, not just the
connected ones: a no-show needs to learn their ticket was failed and a
penalty applied.
2026-09-05 10:17:16 +01:00
Josh Creek 79ab0d1404 fix(multiplayer): serve queue candidates when Redis is down, not just empty
Closes part of the 'live Redis failover' gap in §8.46, found by
reproducing a genuine Redis outage (not just an empty/partial cache)
against CandidateProjection.Snapshot with a killed miniredis instance.

CandidateProjection.Snapshot funnelled two different situations into
the same code path: the index erroring outright (Redis unreachable)
and the index coming back empty (ambiguous — a genuinely empty queue,
or a lost keyspace). Both went through Repair, which itself calls
Index.Rebuild — a second Redis round-trip that fails for exactly the
same reason the first one did. The result: a real Redis outage, or
the window during a failover, made Snapshot fail outright even though
PostgreSQL — the documented authoritative source everywhere
(RedisCandidateIndex's own comment, cmd/matcher, cmd/control-plane's
--redis-addr help text all call it a rebuildable/optional
acceleration layer) — was completely healthy. Matchmaking would stop
entirely on a Redis outage despite the architecture explicitly not
requiring that.

Snapshot now falls back to serving Source (PostgreSQL) directly
whenever the index errors OR comes back empty, and only best-effort
attempts to repopulate Redis afterward — that attempt's outcome is
deliberately ignored, since a caller must never be denied service
just because the opportunistic rebuild also hit the same down Redis.
Snapshot still fails when Source itself is unavailable; the fallback
is not unconditional.

Verified: reproduced the bug first (killed-miniredis Snapshot call
failed even though Source was healthy), then fixed it. go build/vet
clean; all pre-existing store-package tests pass unmodified,
including the two live-redis:7-alpine-container tests
(TestRealRedisCandidateIndexUpsertSnapshotRemove,
TestRealRedisCandidateProjectionRepairsAfterFlush, run against a real
container and torn down after). Two new tests cover the fallback
directly (killed miniredis, Source still served, exactly one Source
call) and that the fallback is not unconditional (both Redis and
Source down still fails). Full go test ./... -race clean across
every server package.

Remaining: live matcher-worker-under-load-during-failover integration,
i.e. running the actual matcher process against a real Redis that
goes down mid-run under concurrent load, not just this unit-level
reproduction.
2026-09-04 17:37:58 +01:00
Josh Creek f09ef7da8f test(multiplayer): cover concurrent proposal-expiry recovery race
Closes the 'concurrent proposal-recovery expiry races' gap noted in
§8.46. GetProposal (read-side recovery) and RespondToProposal both
run the identical expiry-advance SQL in their own transaction, so any
number of them can observe the same past-expiry proposal at once —
this had never been exercised concurrently, only sequentially (the
existing late-response test drives one call at a time).

TestPostgreSQLConcurrentProposalExpiryRecoveryAppliesCooldownsExactlyOnce
races 8 concurrent GetProposal/RespondToProposal calls, each with a
distinct 'now' past the proposal window, against one proposal and
asserts: EXPIRED lands on the proposal and both tickets exactly once,
a PROPOSAL_TIMEOUT penalty lands exactly once per offending player
(not once per racing transaction), and no idempotency row survives a
closed-proposal response. The design already defends against this —
ProposalParticipantExpireSQL only ever flips a still-PENDING row
once, so a losing racer's 'now' can't match
recordProposalTimeoutCooldowns' responded_at filter — this test is
what actually proves that holds under real concurrent load rather
than by inspection.

Verified: real postgres:17-alpine container, go test -tags
integration ./store/... -run
TestPostgreSQLConcurrentProposalExpiryRecoveryAppliesCooldownsExactlyOnce
-race -count=3 clean; full -tags integration ./store/... -race run
clean; full non-integration go build/vet/test -race clean across
every server package; container removed after the run.
2026-09-04 17:26:44 +01:00
Josh Creek a5cbba8ac9 fix(multiplayer): harden live registration verification 2026-09-04 16:48:25 +01:00
Josh Creek d64920b0f9 fix(multiplayer): repair allocated compose verification 2026-09-04 16:38:10 +01:00
Josh Creek e6733bd6cb fix(multiplayer): repair PostgreSQL integration invariants 2026-09-04 15:43:31 +01:00
Josh Creek 947fefc95c fix(multiplayer): dispatch live abandonment lifecycle 2026-09-03 20:55:56 +01:00
Josh Creek 2463713cde feat(multiplayer): persist live reconnect abandonments 2026-09-03 20:53:28 +01:00
Josh Creek aac81c89b6 feat(multiplayer): bind admissions to durable leases 2026-09-03 13:45:39 +01:00
Josh Creek 3e0022ce9c feat(multiplayer): persist connection generation leases 2026-09-03 13:38:13 +01:00
Josh Creek 2e9da3032c fix(multiplayer): complete live results atomically 2026-09-03 13:29:31 +01:00
Josh Creek cee0163eac fix(multiplayer): verify complete durable assignment rosters 2026-09-03 13:22:02 +01:00
Josh Creek a15368ed29 fix(multiplayer): fence client queue cancellation states 2026-09-03 00:15:41 +01:00
Josh Creek 1976c6eac6 fix(multiplayer): promote accepted matches atomically 2026-09-03 00:13:18 +01:00
Josh Creek eaf7ea8748 fix(multiplayer): make match promotion replay lifecycle-safe 2026-09-03 00:10:57 +01:00
Josh Creek f8af212e3f fix(multiplayer): terminate proposal offenders atomically 2026-09-03 00:09:07 +01:00
Josh Creek aa446cfbfe fix(multiplayer): reconcile authoritative initial connections 2026-09-03 00:02:04 +01:00
Josh Creek 55b88a3aa5 fix(multiplayer): converge events through REST 2026-09-02 18:54:10 +01:00
Josh Creek 91658fbc13 fix(multiplayer): recover assignment handoff 2026-09-02 18:47:00 +01:00
Josh Creek 51f6e19339 feat(multiplayer): expose ranked season countdown 2026-09-01 21:49:48 +01:00
Josh Creek eb3b685af0 feat(multiplayer): expose active ranked season 2026-09-01 21:34:57 +01:00
Josh Creek 3151e54ab3 fix(multiplayer): use locked rating for season rollover 2026-09-01 21:32:34 +01:00
Josh Creek 96f311c129 fix(multiplayer): finalize empty ranked seasons 2026-09-01 21:30:23 +01:00
Josh Creek 836cedec3c feat(multiplayer): publish allocation progress events 2026-09-01 21:27:24 +01:00
Josh Creek 701d7a9a2e fix(multiplayer): centralize arena path validation 2026-09-01 21:18:48 +01:00
Josh Creek 2ff348adf0 fix(multiplayer): persist arena identity on allocations 2026-09-01 21:16:42 +01:00
Josh Creek e83f84f527 test(multiplayer): refresh proposal cooldown evidence 2026-09-01 21:10:27 +01:00
Josh Creek bd617dbf06 fix(multiplayer): validate ranked arena paths durably 2026-09-01 21:07:56 +01:00
Josh Creek 84372204fd feat(multiplayer): persist ranked arena allocations 2026-09-01 21:04:55 +01:00
Josh Creek ad59c5f567 test(multiplayer): cover cooldown response boundary 2026-09-01 19:34:16 +01:00
Josh Creek 957bb65a26 feat(multiplayer): enforce proposal timeout cooldowns 2026-09-01 19:32:38 +01:00
Josh Creek 455055c67c feat(multiplayer): enforce proposal decline cooldowns 2026-09-01 19:30:45 +01:00
Josh Creek 72e8d27633 feat(multiplayer): add shared allocation quota 2026-09-01 18:38:06 +01:00
Josh Creek 76c1c3d600 fix(multiplayer): publish stalled allocation recovery 2026-09-01 18:19:28 +01:00
Josh Creek 9ae01ecc5a fix(multiplayer): propagate allocated playlist 2026-09-01 17:00:37 +01:00
Josh Creek cc12260225 feat(multiplayer): expose server shutdown acknowledgement 2026-09-01 16:50:26 +01:00
Josh Creek 8c2dc66c7c fix(multiplayer): release innocent no-show participants 2026-09-01 16:39:46 +01:00
Josh Creek d15d16d593 feat(multiplayer): publish allocation state events 2026-09-01 16:30:35 +01:00
Josh Creek ec82367c50 fix(multiplayer): close initial connect sweep rows 2026-09-01 16:28:08 +01:00
Josh Creek a6d2bdf8bd feat(multiplayer): sweep initial connect outcomes 2026-09-01 16:27:15 +01:00
Josh Creek bae7458668 feat(multiplayer): apply initial connect outcomes durably 2026-09-01 16:26:06 +01:00
Josh Creek a439a1059b fix(multiplayer): fence roster topology at persistence 2026-09-01 16:20:01 +01:00
Josh Creek 68e76b5feb feat(multiplayer): deliver allocated server rosters 2026-09-01 15:54:07 +01:00
Josh Creek 802e5fc96f test(multiplayer): fence conflicting result races 2026-09-01 15:44:47 +01:00
Josh Creek f3b56f70e3 feat(multiplayer): dispatch completed result events 2026-09-01 15:43:32 +01:00
Josh Creek 79a6092b28 feat(multiplayer): wire production proposal outbox delivery 2026-09-01 15:21:49 +01:00
Josh Creek aa93aeec95 test(multiplayer): verify two-player proposal round trip 2026-09-01 15:17:53 +01:00