Skip to content

Server-side scrim endpoint (POST /api/v1/scrim) + atomic budget (#160, #112) - #180

Draft
razam-sherwani wants to merge 1 commit into
stagingfrom
feat/160-scrim-endpoint
Draft

Server-side scrim endpoint (POST /api/v1/scrim) + atomic budget (#160, #112)#180
razam-sherwani wants to merge 1 commit into
stagingfrom
feat/160-scrim-endpoint

Conversation

@razam-sherwani

Copy link
Copy Markdown
Contributor

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 to count matches are scheduled against an atomic volume budget.

Design (per the locked decisions on #160)

  • Unrated. New MatchReason.scrim, skipped by Glicko in GameMatchResultHandler, so a win/loss is recorded on the match but nothing is farmable against the leaderboard.
  • Atomic budget, no read-then-write. scrim_usage(team, window_kind, window_start) is debited by a single INSERT ... 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 counts created/scheduling as well as waiting/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.
  • No turnstile — the budget is the abuse control, matching Remove turnstile #157 which removed it from the sibling endpoint.
  • scrim_usage doubles as the audit trail / telemetry (there's no actuator today).
  • Config scrim.daily-cap / weekly-cap / burst = 20 / 60 / 2; tune from the table.

Closes #112

The user-match limiter in PrivateGameMatchController counted in-flight matches then created up to count more — a check-then-act two concurrent callers could both pass. That path is now @Transactional and takes a PESSIMISTIC_WRITE row 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

V16 adds 'scrim' to the match_reason enum and creates scrim_usage (ALTER TYPE ... ADD VALUE is allowed in a migration txn on PG 12+ since the value isn't used in the same migration). The scrim ladder is a real Ladder rowgame_matches has 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, one reason=scrim/ladder=scrim job 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 no TeamStats row (unrated). Full suite green locally on the JDK-17 toolchain (no CI here, so local is the bar).

Provisioning / out of scope (follow-ups)

🤖 Generated with Claude Code

…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
razam-sherwani marked this pull request as draft August 19, 2026 02:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add server-side scrim endpoint (POST /api/v1/scrim) with atomic volume budget Check-then-act race in scrimmage rate limiter

1 participant