Home > Back-end >  Problem with simultaneous read and save to database
Problem with simultaneous read and save to database

Time:09-25

I have a problem with joining users to game room.

Controller

    [HttpPut("join/")]
    public async Task<ActionResult<string>> JoinRoom([FromQuery] int leagueId, [FromQuery] int userId)
    {
        var data = await classicGameService.JoinRoom(leagueId, userId);

        if (data == "")
        {
            return NotFound();
        }
        else
        {
            return Ok(data);
        }
    }

Service

    public async Task<string> JoinRoom(int leaguePosition, int userId)
    {
        var gameRoom = await 
            context.ClassicGames.Include(x => x.League)
            .FirstOrDefaultAsync(x => x.League.Position == leaguePosition && x.User2 == 0 && x.User2State == (int)EClassicGameUserState.OBSERVER);

        if (gameRoom is null)
        {
            return "";
        }
        else
        {

            gameRoom.User2 = userId;
            gameRoom.User2State = (int)EClassicGameUserState.STAGNATION;
            await context.SaveChangesAsync();
            return $"{gameRoom.Id},{gameRoom.User1}";
        }
    }

When users send request to join simultaneously they getting correct respose for join. It is a big problem for my game. How to make response for the first user and then for the second?

I tried to change the methods to synchronous and there was the same problem.

CodePudding user response:

If your database supports row versioning, such as Timestamps within SQL Server, you can configure your entities to observe these and reject concurrent changes.

For example to reproduce this kind of issue with an Update statement I have an entity called Game with a Player 1 and Player 2 value which I intend should only be updated once at a time. Concurrent access is a problem within web applications as two requests can come in simultaneously and both "capture" data in the same effective state which is perfectly valid for both to try and update. To simulate this you can use the following code:

using (var context = new TestDbContext())
{
    var gameA = context.Games.SingleOrDefault(x => x.GameId == 1 && x.Player2 == null);
    using (var context2 = new TestDbContext())
    {
        var gameB = context2.Games.SingleOrDefault(x => x.GameId == 1 && x.Player2 == null);
        if (gameA != null)
            gameA.Player2 = "Roy";
        if (gameB != null)
            gameB.Player2 = "George";
        context.SaveChanges();
        context2.SaveChanges();
    }
}

In this example we use 2 separate DbContext instances representing our two simultaneous requests. Each loads our desired game satisfied that Player2 is empty. We now have 2 object references, one tracked by each DbContext and we tell both instances to set Player2's name. We then tell the contexts to SaveChanges(). The resulting output will be "George". If we reverse the SaveChanges() call order, the output would be "Roy". We don't want to allow the 2nd call to update. We cannot change the fact that both concurrent reads will get the game and be satisfied that Player2 has not been set unless we were to do something drastic like lock the table or row when trying to read the Games, and only unlock it after saving/aborting. (Pessimistic locking) This would potentially lead to big issues with timeouts or deadlocks.

The alternative is optimistic locking. We update our table to include a Timestamp column (in this example named RowVersion), then configure that column in our EF entity:

public class Game
{
    [Key]
    public int GameId { get; set; }
    public string Player1 { get; set; }
    public string Player2 { get; set; }
    [Timestamp]
    public byte[] RowVersion { get; set; }
}

Now if you run the above code, without any changes at all, the first SaveChanges() call will succeed, while the second SaveChanges() will fail with a DbUpdateConcurrencyException which you will need to handle. Basically in your case you'd likely want to return to the client that their game selection failed, refresh the list, and they'd see that the game was no longer available.

If your storage doesn't support optimistic concurrency then things get a bit more tricky. You would need to develop something like a marshal of sorts where join requests are queued to be performed by a single process responsible for updating player state. The initial call would return a status of something like "Joining" along with a Queue ID which would result in a user seeing a spinner while their client continued to poll with that Queue ID for an update from the marshal. The marshal processes the requests on a first come, first serve basis, and evaluates the rules. When a game is empty and allows Player 2 to join, that queued job gets a "Join Successful" status which comes back to the client on the next poll.. The duplicate request processes and finds Player 2 is filled so that Queued job gets a "Join Failed" response for that client on it's next poll. (Serializing the join operation)

  • Related