Server-side scrim endpoint (POST /api/v1/scrim) + atomic budget (#160, #112) - #180
Draft
razam-sherwani wants to merge 1 commit into
Draft
Server-side scrim endpoint (POST /api/v1/scrim) + atomic budget (#160, #112)#180razam-sherwani wants to merge 1 commit into
razam-sherwani wants to merge 1 commit into
Conversation
…112) POST /api/v1/scrim { competitionSlug, taBotSlug, count } lets students run rate-limited practice matches against TA bots server-side (pitch T3). The caller's team and the named TA bot are resolved server-side — the TA bot's UUID never crosses the wire — and up to `count` matches are scheduled against an atomic volume budget. - Unrated: matches use a new MatchReason.scrim, skipped by Glicko in GameMatchResultHandler, so a win/loss is recorded but nothing is farmable. - Atomic budget: scrim_usage(team, window_kind, window_start) debited by a single INSERT ... ON CONFLICT DO UPDATE ... WHERE count < cap RETURNING count (no read-then-write). Daily + weekly windows plus an in-flight burst cap that counts created/scheduling as well as waiting/in_progress. A weekly rejection rolls back the daily increment (throw out of the per-unit transaction). Rejection returns 429 + Retry-After. Config: scrim.daily-cap/weekly-cap/burst (20/60/2). No turnstile (the budget is the abuse control; matches #157). - scrim_usage doubles as the scrim audit trail / telemetry. - The scrim ladder is a real Ladder row (game_matches has a composite FK on (competition, ladder)); auto-provisioned for new competitions alongside the validation ladder, and the endpoint 404s cleanly when a competition hasn't been provisioned. Closes #112: the user-match limiter in PrivateGameMatchController counted in-flight matches then created up to `count` more, a check-then-act two callers could both pass. The create path is now @transactional and takes a PESSIMISTIC_WRITE row lock on the initiating team first, serializing concurrent requests for that team so the cap can't be exceeded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
razam-sherwani
marked this pull request as draft
August 19, 2026 02:08
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #160. Closes #112.
What
POST /api/v1/scrim { competitionSlug, taBotSlug, count }— the pitch's T3 protection tier: students run rate-limited practice matches against TA bots on our runners. The caller's team and the named TA bot are resolved server-side (the TA bot's UUID never crosses the wire), and up tocountmatches are scheduled against an atomic volume budget.Design (per the locked decisions on #160)
MatchReason.scrim, skipped by Glicko inGameMatchResultHandler, so a win/loss is recorded on the match but nothing is farmable against the leaderboard.scrim_usage(team, window_kind, window_start)is debited by a singleINSERT ... ON CONFLICT DO UPDATE SET count = count + 1 WHERE count < :cap RETURNING count— an empty result means the cap was hit. Daily + weekly windows, plus an in-flight burst cap that countscreated/schedulingas well aswaiting/in_progress(the hole the issue calls out). A weekly rejection rolls back the daily increment (throw out of the per-unit transaction). Rejection →429+Retry-After.scrim_usagedoubles as the audit trail / telemetry (there's no actuator today).scrim.daily-cap/weekly-cap/burst= 20 / 60 / 2; tune from the table.Closes #112
The user-match limiter in
PrivateGameMatchControllercounted in-flight matches then created up tocountmore — a check-then-act two concurrent callers could both pass. That path is now@Transactionaland takes aPESSIMISTIC_WRITErow lock on the initiating team before the count, serializing same-team requests so the cap can't be exceeded. (An in-flight concurrency cap doesn't map to scrim's windowed-counter pattern, so the lock is the right primitive here.)Migration
V16adds'scrim'to thematch_reasonenum and createsscrim_usage(ALTER TYPE ... ADD VALUEis allowed in a migration txn on PG 12+ since the value isn't used in the same migration). The scrim ladder is a realLadderrow —game_matcheshas a composite FK on(competition, ladder)— auto-provisioned for new competitions next to the validation ladder; the endpoint returns a clean 404 when a competition hasn't been provisioned.Tests
ScrimControllerIT(Testcontainers PG+Rabbit): happy path (200, onereason=scrim/ladder=scrimjob to the TA bot, response omits the TA bot UUID); daily-cap → 429 + Retry-After with the counter never exceeding the cap; burst → 429; unknown bot → 404; regular (non-ta_bot) opponent → 400; and a scrim result creates noTeamStatsrow (unrated). Full suite green locally on the JDK-17 toolchain (no CI here, so local is the bar).Provisioning / out of scope (follow-ups)
competition.<slug>.scrim) and TA-bot teams present — data/ops, not endpoint code.allowScrimcompetition toggle yet — v1 gates onisActive().🤖 Generated with Claude Code