0

I have a REST API (distributed with across multiple hosts/containers) that uses MongoDB as a database. Among the collections in my database, I want to focus on the Users and Games collections in this example.

Let's say I have an endpoint (called by the user/client) called /join_game. This endpoint will step-through the following logic:

  1. Check if game is open (query the Games model)
  2. If the game is open, allow the user to join (continue with below logic)
  3. Add player to the participants field in the Games model and update that document
  4. Update some fields in the Users document (stats, etc.)

And let there be another endpoint (called by a cron job on the server) called /close_game which steps-through the following logic:

  1. Close the game (update the Games Model)
  2. Determine the winner & update their stats (update in the Users model)

Now I know that the following race condition is possible between two concurrent requests handled by each of the endpoints:

  1. request A to /join_game called by a client - /join_game controller checks if game is open (it is so it proceeds with the rest of the endpoint logic)
  2. request B to /close_game called internally by the server - /close_game controller sets the game as closed within the game's document

If these requests are concurrent and request A is called before request B, then the remaining /join_game logic potentially might be executed despite that the game is technically locked. Now this is obvious behavior I don't want and can introduce many errors/unexpected outcomes.

To prevent this, I looked into using the transactions API since it makes all database operations within the transaction atomic. However, I'm not sure if transactions actually solve my case since I'm not sure if they place a complete lock on the documents being queried and modified (I read that mongodb uses shared locks for reads and exclusive locks for writes). And if they do put a complete lock, would other database calls to those documents within the transaction just wait for those transactions to complete? I also read that transactions abort if they wait after a certain period of time (which can also lead to unwanted behavior).

If transactions are not the way to go about preventing race conditions across multiple different endpoints, I'd like to know of any good alternative methods.

Originally, I was using an in-memory queue for handling these race conditions which seemed to have work on a server running the REST API on a single node. But as I scale up, managing this queue amongst distributed servers will become more of an issue so I'd like to handle these race-conditions directly within mongo if possible.

Thanks!

Dave
  • 65
  • 1
  • 12
  • We need more details about the extended `join_game` logic, does it run for 30min after? is it just another update on another collection? does it return a relevant response to the client that needs to be handled as well ? – Tom Slabbaert Jul 11 '22 at 06:04
  • @TomSlabbaert the `join_game` logic is called after a user hits a button that allows them to join a game (added as a participant to the game document) while that game is set to be open, there are no timed events associated with `join_game`. There are `init_game` and `close_end` controllers that are ran by a cron job that run for a total of 30 sec and control when the game is open and closed. The join game does return a relevant response since it returns information about the user that joined the game and broadcasts it to the rest of the participants. – Dave Jul 11 '22 at 16:36

1 Answers1

0

From my understanding, it looks like you don't need to use Transactions within MongoDB but you can use MongoDB atomic update operations.

Each operation to a document gets executed one at a time, thus meaning if you join a game while a close game gets called and executes first then you just won't be able to join.

This is why schema design is important, you'll need to figure out how you can model your document to allow atomic updates to solve the concurrency issues. For updating other collections which are view which might be how many times a player has won/lost/games joined. I'd make that all eventually consistent on back of events.

You can also use the FindOneAndModify operations which can return the new state of the document once the update has been completed. Which becomes really useful for dealing with concurrency.

db.test.insert({ _id: 1, name: "Game 1", players : [ ], isClosed: false})
db.test.insert({ _id: 2, name: "Game 2", players : [ ], isClosed: false})
db.test.insert({ _id: 3, name: "Game 2", players : [ ], isClosed: false})

db.test.update({ _id: 1, isClosed: false}, {$push: { players: 20} })
db.test.update({ _id: 1, isClosed: false}, {isClosed: true, players : [] })
Kevin Smith
  • 13,746
  • 4
  • 52
  • 77