The Part-1 of designing multiuser chat room with app engine deals with providing a basic information of how to create a simple chatroom/gameroom with App Engine Channel API. That article was more based on the tic-tac-toe example provided by the Google App Engine team.
In this post, I will talk about the ways in which this can be optimized using memcache.
But before that, let’s look at the complications with the previous one
- The Channel ID is a function of only userid. This means that one user can not login from multiple clients and can expect consistency. There will always be an inconsistency and improper outgoing messages on the channels.
- Too much datastore operations. For every action, there is too much datastore operations going on. As the number of players keep on increasing, this problem becomes more intense. A developer reported that he had to wait several seconds for things to happen while doing it with ~30 players. This is an optimized version and will have much better serving time
- Coding style. Well frankly, I was not satisfied with the coding style in prev version, so in the hope of creating better and beautiful code, I decided to rewrite it and I came up with this new file tournament.py, which is pretty neat.
First of all, in order to get an insight of effective memcaching in datastore entities, go through this post on Nick’s blog. This has been completely used.
EfficientModel
In order to do good optimizations, I have a base class EfficientModel from which my datastore classes will be derived. Entities of kind EfficientModel are designed to be in memcache most of the time they are required. All the operations on these entities take place in memcache only. After the memcached entity has been updated “certain” number of times, the change is replicated in the datastore. The EfficientModel has an attribute mc_version, which stores the version number of this entity in memcache. The concept is that every entity has a revision number associated. Whenever there is any change in one of the attributes/property of the entity it’s version number increases by one. mc_version stored the version number of entity in memcache. db_version stores the version number of entity in the datastore. The difference between memcache version and datastore version is called as FAULT NUMBER. When fault_number goes beyond a certain number known as fault_tolerance, then the memcache entity and the datastore entity are sync’d.
class EfficientModel(db.Model):
mc_version = db.IntegerProperty(default = 0)
db_version = db.IntegerProperty(default = 0)
created = db.DateTimeProperty(auto_now_add = True)
updated = db.DateTimeProperty(auto_now = True)
@property
def keyname(self):
return self.key().name()
@property
def memcache_key(self):
raise NotImplementedError
@classmethod
def from_id(cls, id):
existing_entity_mc = deserialize_entities(memcache.get(id))
if existing_entity_mc is None:
existing_entity_db = cls.get_by_key_name(id, parent = cls.find_parent(id))
if existing_entity_db is None:
return cls(key_name = unicode(id))
return existing_entity_db
return existing_entity_mc
@classmethod
def fetch_from_id(cls, id):
return cls(key_name = unicode(id))._get()
def _from_memcache(self):
return deserialize_entities(memcache.get(self.memcache_key))
def _get(self):
memcached_entity = self._from_memcache()
if memcached_entity is not None:
return memcached_entity
return self.get_or_insert(key_name = self.keyname)
def _store(self, force = False):
self.mc_version += 1
memcache.set(self.keyname, serialize_entities(self))
if self.mc_version – self.db_version >= FAULT_TOLERANCE or force:
logging.info(‘sync_to_db started for %s. mc_version: %d, db_version: %d’%(
self.keyname, self.mc_version, self.db_version))
self.sync_to_db()
elif self.mc_version < self.db_version:
logging.info('sync_from_db started')
self.sync_from_db()
def sync_to_db(self):
self.db_version = self.mc_version
db.put(self)
self._store()
def sync_from_db(self):
from_ds = db.get(self.keyname)
memcache.set(self.keyname, serialize_entities(from_ds))
self._store()
@classmethod
def find_parent(cls, id):
raise NotImplementedError
Properties, Classmethods and Functions
- keyname, property. : Returns the key name of the entity. Equivalent to
.key().name()
- memcache_key, property : The derived class is expected to define this property. This is supposed to return the key which will be used in memcache while storing and retrieving this entity. It is assumed at some places the memcache_key shall be same as keyname.
- from_id(id), classmethod : This classmethod returns the entity based on the id passed. It first tries to fetch the entity from memcache. If not found, it attempts to fetch the entity from datastore. If that is not found, then it creates an entity with the key_name as the id passed and returns that.
- _from_memcache, private function : Returns the memcached snapshot of the entity
- _get, private function : Attempts to get self from memcache. If not found, goes through a get_or_insert call.
- fetch_from_id(id), classmethod : The difference between fetch_from_id and from_is is that, fetch_from_id always creates an entity in the datastore if it does not already exists. While in case of from_id a dummy object (one which is not in the datastore, yet) is returned.
- sync_from_db, function : Sync the entities from datastore to memcache. Essentially, the entity is copied to memcache from datastore and the versions are updated
- sync_to_db, function : Sync the entities from memcache to data store. Essentially, the entity is copied from memcahe to datastore and the versions are updated
- _store, private function : Stores the entity in memcache and updates the mc_version. If the fault becomes more than fault tolerance, it syncs the entity across datastore and memcache.
- find_parent(id), classmethod : This is not implemented and the derived class is expected to work on this. This is supposed to return the key name of the parent entity of this entity.
Channels
The channel ids created are a function of userid as well as the time.time(). Whenever a channel id is created, it is stored as a property in the Player model as well as the Game model. Duplicacy of data, helps do better reads.
def gen_channel(userid):
seed = userid + str(int(time.time()))
return md5.md5(seed).hexdigest()
Player Class
The Player Class has more or less the same functions, but their writing style became little different due to change in coding style.
class Player(EfficientModel):
“””
Stores information about which player is added into which game
room. This of course assumes that a player can be in only one
game room at a time.
“””
assets = db.TextProperty(default = ”)
name = db.StringProperty()
channels = db.StringListProperty()
@property
def memcache_key(self):
return self.key().name()
@property
def gameroom(self):
if self.parent():
return self.parent().key().name()
def get_channels(self):
return self._get().channels
def create_channel(self):
new_channel = gen_channel(self.keyname)
self.channels.append(new_channel)
self._store()
return new_channel
def die(self):
raise NotImplementedError
def do_action(self, action, **kwargs):
raise NotImplementedError
@classmethod
def find_parent(cls, id):
return Game.all(keys_only = True).filter(‘players = ‘, id).get()
def chat(self, message):
if self.gameroom is not None:
self._store()
Game.from_id(self.gameroom).update_chat(self, message)
def leave_tournament(self):
if self.gameroom is not None:
Game.from_id(self.gameroom).expel(self)
Game Class
The important parts of the class are shown here. For a more detailed version, have a look at the source code.
class Game(EfficientModel):
“””
The Model to store the details of a particular
game room.
“””
players = db.StringListProperty()
chat = db.TextProperty()
active = db.BooleanProperty(default = True)
channels = db.StringListProperty()
deltas = {}
@property
def memcache_key(self):
return ‘tournament_’ + self.key().name()
@classmethod
def join_latest_or_new(cls, player):
latest_roomkey = memcache.get(LATEST_GAMEROOM)
if latest_roomkey:
cls.from_id(latest_roomkey).add_player(player)
return latest_roomkey
return cls().new(player)
def can_add_player(self):
if MAX_PARTICIPANTS == -1: return True
return len(self.players) < MAX_PARTICIPANTS
def add_player(self, player):
if self.can_add_player():
logging.info('Adding to existing game')
if player.keyname not in self.players:
self.players.append(player.keyname)
self._store()
p = Player.get_or_insert(key_name = player.keyname,
parent = self)
memcache.set(player.keyname, serialize_entities(player))
self.deltas.update({'new_player' : 1,
'name' : player.keyname,})
self.update_channels(player)
return self.send_updates()
else:
return self.new(player)
@classmethod
def new(cls, player):
if not player: return
logging.info('Creating new gameroom…')
new_room = cls(key_name = str(time.time()),
players = [player.keyname])
sim_player = Player(key_name = player.keyname,
parent = new_room,
channels = player.channels)
db.put([new_room, sim_player])
memcache.set_multi({LATEST_GAMEROOM : new_room.key().name(),
new_room.memcache_key: serialize_entities(new_room),
sim_player.keyname : serialize_entities(sim_player),
})
new_room.update_channels(sim_player)
return new_room
@classmethod
def continue_tournament(cls, player):
room = cls.from_id(player.gameroom)
room.update_channels(player)
def update_channels(self, player):
new_channels = player.get_channels()
if isinstance(new_channels, list):
self.channels.extend(new_channels)
if isinstance(new_channels, str):
self.channels.append(new_channels)
self.channels = list(set(self.channels))
self._store(True)
def send_updates(self):
message = simplejson.dumps(self.deltas)
for channel_id in self.channels:
channel.send_message(channel_id, message)
@classmethod
def find_parent(cls, id):
return None
def update_chat(self, player, message):
self.deltas['chat'] = message
if self.chat:
self.chat += '
' + self.deltas.get('chat')
else:
self.chat = self.deltas.get('chat')
self._store()
self.send_updates()
def end(self):
"End the game"
player_keys = [db.Key.from_path('Game', self.keyname, 'Player', x) for x in self.players]
db_keys = [db.key()]
db_keys.extend(player_keys)
memcache_keys = self.players
memcache_keys.append(self.memcache_key)
memcache.delete_multi(memcache_keys)
db.delete(db_keys)
def expel(self, player):
"Expel player from the game"
memcache.delete(player.memcache_key)
def txn(game_obj, player_key, player_id):
db.delete(player_key)
game_obj.players.pop(player_id)
game_obj.put()
db.run_in_transaction(txn, self, db.Key(player.keyname), player.keyname)
self.deltas.update({'expel': 1,
'name' : player.keyname})
self.send_updates()
This is a better optimized version. I am still working on it on a bigger and wider scale and will keep updating about the scaling issues of this approach and how to fix them.
Resources
- Multiuser Chatroom with App Engine Channel API – Part 1: http://blog.myblive.com/2010/12/multiuser-chatroom-with-app-engine.html
- Source Code: http://code.google.com/p/pranav/source/browse/chat-channel
- Channel API: http://code.google.com/appengine/docs/python/channel
- Discuss: Google Groups Discussion
- Tic Tac Toe App: http://code.google.com/p/channel-tac-toe