diff --git a/.env.local b/.env.local new file mode 100644 index 0000000..d596e7f --- /dev/null +++ b/.env.local @@ -0,0 +1 @@ +TEMP_DB_URL=postgresql://heimdall_arena:uqwu-EDfz.!3KT!2WmV3@postgresql9c.sldb.iwinv.net/heimdall_arena_db?statusColor=F8F8F8&enviroment=production \ No newline at end of file diff --git a/ArenaService.Shared/Repositories/AvailableOpponentRepository.cs b/ArenaService.Shared/Repositories/AvailableOpponentRepository.cs index fa6fb75..bef8239 100644 --- a/ArenaService.Shared/Repositories/AvailableOpponentRepository.cs +++ b/ArenaService.Shared/Repositories/AvailableOpponentRepository.cs @@ -99,48 +99,38 @@ public async Task> RefreshAvailableOpponents( List<(Address, int)> opponents ) { - using var transaction = await _context.Database.BeginTransactionAsync(); + // Runs in the caller's transaction so the refresh commits together with the ticket deduction. + var existOpponents = await GetAvailableOpponents(avatarAddress, roundId); - try + foreach (var existOpponent in existOpponents) { - var existOpponents = await GetAvailableOpponents(avatarAddress, roundId); + existOpponent.DeletedAt = DateTime.UtcNow; - foreach (var existOpponent in existOpponents) - { - existOpponent.DeletedAt = DateTime.UtcNow; - - _context.AvailableOpponents.Update(existOpponent); - } + _context.AvailableOpponents.Update(existOpponent); + } - var newOpponents = new List(); + var newOpponents = new List(); - foreach (var opponent in opponents) - { - var newOpponent = new AvailableOpponent - { - AvatarAddress = avatarAddress, - SeasonId = seasonId, - RoundId = roundId, - GroupId = opponent.Item2, - OpponentAvatarAddress = opponent.Item1, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow - }; - - newOpponents.Add(newOpponent); - } - - await _context.AvailableOpponents.AddRangeAsync(newOpponents); - await _context.SaveChangesAsync(); - await transaction.CommitAsync(); - - return newOpponents; - } - catch + foreach (var opponent in opponents) { - await transaction.RollbackAsync(); - throw; + var newOpponent = new AvailableOpponent + { + AvatarAddress = avatarAddress, + SeasonId = seasonId, + RoundId = roundId, + GroupId = opponent.Item2, + OpponentAvatarAddress = opponent.Item1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + newOpponents.Add(newOpponent); } + + await _context.AvailableOpponents.AddRangeAsync(newOpponents); + await _context.SaveChangesAsync(); + + return newOpponents; } public async Task UpdateAvailableOpponent( diff --git a/ArenaService.Shared/Repositories/TicketRepository.cs b/ArenaService.Shared/Repositories/TicketRepository.cs index 2a6acda..282ac27 100644 --- a/ArenaService.Shared/Repositories/TicketRepository.cs +++ b/ArenaService.Shared/Repositories/TicketRepository.cs @@ -162,6 +162,8 @@ Task DeductBattleTicket( bool isVictory ); + Task TryDeductRefreshTicket(int refreshTicketStatusPerRoundId); + Task TryUpdateBattleTicketStatusPerRound( int roundId, @@ -696,6 +698,18 @@ bool isVictory return false; } + public async Task TryDeductRefreshTicket(int refreshTicketStatusPerRoundId) + { + var rowsAffected = await _context.RefreshTicketStatusesPerRound + .Where(rts => rts.Id == refreshTicketStatusPerRoundId && rts.RemainingCount > 0) + .ExecuteUpdateAsync(setters => setters + .SetProperty(rts => rts.RemainingCount, rts => rts.RemainingCount - 1) + .SetProperty(rts => rts.UsedCount, rts => rts.UsedCount + 1) + .SetProperty(rts => rts.UpdatedAt, DateTime.UtcNow)); + + return rowsAffected > 0; + } + public async Task TryUpdateBattleTicketStatusPerRound( int roundId, Address avatarAddress, diff --git a/ArenaService/Controllers/AvailableOpponentController.cs b/ArenaService/Controllers/AvailableOpponentController.cs index 04c99ba..0100e20 100644 --- a/ArenaService/Controllers/AvailableOpponentController.cs +++ b/ArenaService/Controllers/AvailableOpponentController.cs @@ -1,6 +1,8 @@ namespace ArenaService.Controllers; +using System.Data; using ArenaService.Shared.Constants; +using ArenaService.Shared.Data; using ArenaService.Shared.Dtos; using ArenaService.Shared.Exceptions; using ArenaService.Shared.Extensions; @@ -22,6 +24,7 @@ public class AvailableOpponentController : ControllerBase private readonly IParticipateService _participateService; private readonly IRankingRepository _rankingRepo; private readonly IAllClanRankingRepository _allClanRankingRepo; + private readonly ArenaDbContext _dbContext; public AvailableOpponentController( IAvailableOpponentRepository availableOpponentRepo, @@ -30,7 +33,8 @@ public AvailableOpponentController( ISeasonCacheRepository seasonCacheRepo, IParticipateService participateService, IAllClanRankingRepository allClanRankingRepo, - IRankingRepository rankingRepo + IRankingRepository rankingRepo, + ArenaDbContext dbContext ) { _availableOpponentRepo = availableOpponentRepo; @@ -40,6 +44,7 @@ IRankingRepository rankingRepo _participateService = participateService; _allClanRankingRepo = allClanRankingRepo; _rankingRepo = rankingRepo; + _dbContext = dbContext; } [HttpGet] @@ -211,26 +216,30 @@ public async Task RequestFreeRefresh() cachedSeason.StartBlock == cachedRound.StartBlock ); - var availableOpponents = await _availableOpponentRepo.RefreshAvailableOpponents( - cachedSeason.Id, - cachedRound.Id, - avatarAddress, - opponents.Select(o => (o.Value.AvatarAddress, o.Key)).ToList() - ); - - await _ticketRepo.UpdateRefreshTicketStatusPerRound( - refreshTicketStatusPerRound, - rts => + // Deducting first locks the ticket status row, so concurrent refreshes by the same + // avatar are serialized and each one sees the opponents committed by the previous one. + using (var transaction = await _dbContext.Database.BeginTransactionAsync(IsolationLevel.ReadCommitted)) + { + if (!await _ticketRepo.TryDeductRefreshTicket(refreshTicketStatusPerRound.Id)) { - rts.RemainingCount -= 1; - rts.UsedCount += 1; + await transaction.RollbackAsync(); + return BadRequest("NO_REFRESH_TICKETS"); } - ); - await _ticketRepo.AddRefreshTicketUsageLog( - refreshTicketStatusPerRound.Id, - availableOpponents.Select(o => o.Id).ToList() - ); + var availableOpponents = await _availableOpponentRepo.RefreshAvailableOpponents( + cachedSeason.Id, + cachedRound.Id, + avatarAddress, + opponents.Select(o => (o.Value.AvatarAddress, o.Key)).ToList() + ); + + await _ticketRepo.AddRefreshTicketUsageLog( + refreshTicketStatusPerRound.Id, + availableOpponents.Select(o => o.Id).ToList() + ); + + await transaction.CommitAsync(); + } var availableOpponentsResponses = new List();