Skip to content

Make refresh ticket deduction atomic with opponent refresh - #192

Merged
Atralupus merged 1 commit into
planetarium:mainfrom
Atralupus:fix/atomic-refresh-ticket
Oct 8, 2026
Merged

Atralupus merged 1 commit into
planetarium:mainfrom
Atralupus:fix/atomic-refresh-ticket

Conversation

@Atralupus

Copy link
Copy Markdown
Member

Problem

POST /available-opponents/refresh committed three steps separately:

  1. RefreshAvailableOpponents (own transaction)
  2. UpdateRefreshTicketStatusPerRound (read-modify-write on the tracked entity)
  3. AddRefreshTicketUsageLog

This caused two issues observed on heimdall in season 44, round 1:

  • Free refresh on partial failure: step 1 committed but step 2 failed, so a new opponent set was created without deducting a ticket (one set with no usage log, used_count one lower than the number of sets). This gave the user one extra +22 opportunity in the round.
  • Lost deduction under concurrent requests: two overlapping refreshes both read the same remaining_count and wrote the same value back (5 usage logs vs used_count = 4). Because neither saw the other's uncommitted set, two opponent sets stayed active at the same time (10 active opponents).

Both affected records have been corrected manually in the DB.

Fix

  • Add ITicketRepository.TryDeductRefreshTicket, which does a conditional ExecuteUpdate (remaining_count > 0).
  • In the controller, run the deduction, opponent refresh and usage log in a single ReadCommitted transaction, deducting first. The UPDATE holds the status row lock until commit, so concurrent refreshes for the same avatar are serialized and the later one soft-deletes the set committed by the earlier one. If no ticket is left, it returns NO_REFRESH_TICKETS as before.
  • Remove the inner transaction from RefreshAvailableOpponents. It is a single SaveChanges and now runs in the caller's transaction (a nested BeginTransaction would throw).

Test

  • dotnet build passes
  • dotnet test ArenaService.Tests passes (39/39)

🤖 Generated with Claude Code

The opponent refresh, ticket deduction and usage log were committed
separately, so a failure after the refresh left new opponents without a
deducted ticket. The deduction also read-modified-wrote the status row,
letting concurrent refreshes lose a deduction and leave two opponent
sets active.

Run all three in one transaction and deduct with a conditional
ExecuteUpdate (remaining_count > 0) first, which locks the status row
and serializes concurrent refreshes for the same avatar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Atralupus
Atralupus merged commit e2f0025 into planetarium:main Oct 8, 2026
2 checks passed
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.

1 participant