thomers
thomers

Reputation: 2693

Concurrency between Meteor.setTimeout and Meteor.methods

In my Meteor application to implement a turnbased multiplayer game server, the clients receive the game state via publish/subscribe, and can call a Meteor method sendTurn to send turn data to the server (they cannot update the game state collection directly).

var endRound = function(gameRound) {
    // check if gameRound has already ended / 
    // if round results have already been determined
    //     --> yes: 
                  do nothing
    //     --> no:
    //            determine round results
    //            update collection
    //            create next gameRound
};

Meteor.methods({
    sendTurn: function(turnParams) {
        // find gameRound data 
        // validate turnParams against gameRound
        // store turn (update "gameRound" collection object) 
        // have all clients sent in turns for this round?
        //      yes --> call "endRound"
        //      no  --> wait for other clients to send turns
    }
});

To implement a time limit, I want to wait for a certain time period (to give clients time to call sendTurn), and then determine the round result - but only if the round result has not already been determined in sendTurn.

How should I implement this time limit on the server?

My naive approach to implement this would be to call Meteor.setTimeout(endRound, <roundTimeLimit>).

Questions:

Upvotes: 4

Views: 366

Answers (1)

Andrew Mao
Andrew Mao

Reputation: 36900

Great question, and it's trickier than it looks. First off I'd like to point out that I've implemented a solution to this exact problem in the following repos:

https://github.com/ldworkin/meteor-prisoners-dilemma https://github.com/HarvardEconCS/turkserver-meteor

To summarize, the problem basically has the following properties:

  • Each client sends in some action on each round (you call this sendTurn)
  • When all clients have sent in their actions, run endRound
  • Each round has a timer that, if it expires, automatically runs endRound anyway
  • endRound must execute exactly once per round regardless of what clients do

Now, consider the properties of Meteor that we have to deal with:

  • Each client can have exactly one outstanding method to the server at a time (unless this.unblock() is called inside a method). Following methods wait for the first.
  • All timeout and database operations on the server can yield to other fibers

This means that whenever a method call goes through a yielding operation, values in Node or the database can change. This can lead to the following potential race conditions (these are just the ones I've fixed, but there may be others):

  • In a 2-player game, for example, two clients call sendTurn at exactly same time. Both call a yielding operation to store the turn data. Both methods then check whether 2 players have sent in their turns, finding the affirmative, and then endRound gets run twice.
  • A player calls sendTurn right as the round times out. In that case, endRound is called by both the timeout and the player's method, resulting running twice again.
  • Incorrect fixes to the above problems can result in starvation where endRound never gets called.

You can approach this problem in several ways, either synchronizing in Node or in the database.

  • Since only one Fiber can actually change values in Node at a time, if you don't call a yielding operation you are guaranteed to avoid possible race conditions. So you can cache things like the turn states in memory instead of in the database. However, this requires that the caching is done correctly and doesn't carry over to clustered environments.
  • Move the endRound code outside of the method call itself, using something else to trigger it. This is the approach I've taken which ensures that only the timer or the final player triggers the end of the round, not both (see here for an implementation using observeChanges).
  • In a clustered environment you will have to synchronize using only the database, probably with conditional update operations and atomic operators. Something like the following:

    var currentVal;
    
    while(true) {
      currentVal = Foo.findOne(id).val; // yields
    
      if( Foo.update({_id: id, val: currentVal}, {$inc: {val: 1}}) > 0 ) {
        // Operation went as expected
        // (your code here, e.g. endRound)
        break;
      }
      else {
        // Race condition detected, try again
      }
    } 
    

The above approach is primitive and probably results in bad database performance under high loads; it also doesn't handle timers, but I'm sure with some thinking you can figure out how to extend it to work better.

You may also want to see this timers code for some other ideas. I'm going to extend it to the full setting that you described once I have some time.

Upvotes: 3

Related Questions