DB: use 'messages' to track allocations, not 'allocations'

This removes the 'allocations' table entirely, and cleans up the way we
prune old messages. This should make it easier to summarize each
connection (for usage stats) when it gets deallocated, as well as making
pruning more reliable.
This commit is contained in:
Brian Warner 2015-11-13 18:22:37 -08:00
parent bb97729a23
commit 26c7008c23
2 changed files with 102 additions and 54 deletions

View File

@ -13,15 +13,8 @@ CREATE TABLE `messages`
`channelid` INTEGER, `channelid` INTEGER,
`side` VARCHAR, `side` VARCHAR,
`phase` VARCHAR, -- not numeric, more of a PAKE-phase indicator string `phase` VARCHAR, -- not numeric, more of a PAKE-phase indicator string
-- phase="_allocate" and "_deallocate" are used internally
`body` VARCHAR, `body` VARCHAR,
`when` INTEGER `when` INTEGER
); );
CREATE INDEX `messages_idx` ON `messages` (`appid`, `channelid`); CREATE INDEX `messages_idx` ON `messages` (`appid`, `channelid`);
CREATE TABLE `allocations`
(
`appid` VARCHAR,
`channelid` INTEGER,
`side` VARCHAR
);
CREATE INDEX `allocations_idx` ON `allocations` (`channelid`);

View File

@ -13,6 +13,9 @@ MB = 1000*1000
CHANNEL_EXPIRATION_TIME = 3*DAY CHANNEL_EXPIRATION_TIME = 3*DAY
EXPIRATION_CHECK_PERIOD = 2*HOUR EXPIRATION_CHECK_PERIOD = 2*HOUR
ALLOCATE = u"_allocate"
DEALLOCATE = u"_deallocate"
def json_response(request, data): def json_response(request, data):
request.setHeader(b"content-type", b"application/json; charset=utf-8") request.setHeader(b"content-type", b"application/json; charset=utf-8")
return (json.dumps(data)+"\n").encode("utf-8") return (json.dumps(data)+"\n").encode("utf-8")
@ -167,9 +170,14 @@ class Deallocator(RelayResource):
appid = data["appid"] appid = data["appid"]
channelid = int(data["channelid"]) channelid = int(data["channelid"])
side = data["side"] side = data["side"]
if not isinstance(side, type(u"")):
raise TypeError("side must be string, not '%s'" % type(side))
mood = data.get("mood")
#print("DEALLOCATE", appid, channelid, side) #print("DEALLOCATE", appid, channelid, side)
app = self._relay.get_app(appid) app = self._relay.get_app(appid)
deleted = app.maybe_free_child(channelid, side) channel = app.get_channel(channelid)
deleted = channel.deallocate(side, mood)
response = {"status": "waiting"} response = {"status": "waiting"}
if deleted: if deleted:
response = {"status": "deleted"} response = {"status": "deleted"}
@ -193,6 +201,8 @@ class Channel:
" WHERE `appid`=? AND `channelid`=?" " WHERE `appid`=? AND `channelid`=?"
" ORDER BY `when` ASC", " ORDER BY `when` ASC",
(self._appid, self._channelid)).fetchall(): (self._appid, self._channelid)).fetchall():
if row["phase"] in (u"_allocate", u"_deallocate"):
continue
messages.append({"phase": row["phase"], "body": row["body"]}) messages.append({"phase": row["phase"], "body": row["body"]})
data = {"welcome": self._welcome, "messages": messages} data = {"welcome": self._welcome, "messages": messages}
return data return data
@ -204,6 +214,8 @@ class Channel:
" WHERE `appid`=? AND `channelid`=?" " WHERE `appid`=? AND `channelid`=?"
" ORDER BY `when` ASC", " ORDER BY `when` ASC",
(self._appid, self._channelid)).fetchall(): (self._appid, self._channelid)).fetchall():
if row["phase"] in (u"_allocate", u"_deallocate"):
continue
yield json.dumps({"phase": row["phase"], "body": row["body"]}) yield json.dumps({"phase": row["phase"], "body": row["body"]})
def remove_listener(self, listener): def remove_listener(self, listener):
self._listeners.discard(listener) self._listeners.discard(listener)
@ -213,21 +225,76 @@ class Channel:
for listener in self._listeners: for listener in self._listeners:
listener(data) listener(data)
def add_message(self, side, phase, body): def _add_message(self, side, phase, body):
db = self._db db = self._db
db.execute("INSERT INTO `messages`" db.execute("INSERT INTO `messages`"
" (`appid`, `channelid`, `side`, `phase`, `body`, `when`)" " (`appid`, `channelid`, `side`, `phase`, `body`, `when`)"
" VALUES (?,?,?,?, ?,?)", " VALUES (?,?,?,?, ?,?)",
(self._appid, self._channelid, side, phase, (self._appid, self._channelid, side, phase,
body, time.time())) body, time.time()))
db.execute("INSERT INTO `allocations`"
" (`appid`, `channelid`, `side`)"
" VALUES (?,?,?)",
(self._appid, self._channelid, side))
db.commit() db.commit()
def allocate(self, side):
self._add_message(side, ALLOCATE, None)
def add_message(self, side, phase, body):
self._add_message(side, phase, body)
self.broadcast_message(phase, body) self.broadcast_message(phase, body)
return self.get_messages() return self.get_messages()
def deallocate(self, side, mood):
self._add_message(side, DEALLOCATE, mood)
db = self._db
seen = set([row["side"] for row in
db.execute("SELECT `side` FROM `messages`"
" WHERE `appid`=? AND `channelid`=?",
(self._appid, self._channelid))])
freed = set([row["side"] for row in
db.execute("SELECT `side` FROM `messages`"
" WHERE `appid`=? AND `channelid`=?"
" AND `phase`=?",
(self._appid, self._channelid, DEALLOCATE))])
if seen - freed:
return False
self.delete_and_summarize()
return True
def is_idle(self):
if self._listeners:
return False
c = self._db.execute("SELECT `when` FROM `messages`"
" WHERE `appid`=? AND `channelid`=?"
" ORDER BY `when` DESC LIMIT 1",
(self._appid, self._channelid))
rows = c.fetchall()
if not rows:
return True
old = time.time() - CHANNEL_EXPIRATION_TIME
if rows[0]["when"] < old:
return True
return False
def delete_and_summarize(self):
# TODO: summarize usage, write into DB
db = self._db
db.execute("DELETE FROM `messages`"
" WHERE `appid`=? AND `channelid`=?",
(self._appid, self._channelid))
db.commit()
# It'd be nice to shut down any EventSource listeners here. But we
# don't hang on to the EventsProtocol, so we can't really shut it
# down here: any listeners will stick around until they shut down
# from the client side. That will keep the Channel object in memory,
# but it won't be reachable from the AppNamespace, so no further
# messages will be sent to it. Eventually, when they close the TCP
# connection, self.remove_listener() will be called, ep.sendEvent
# will be removed from self._listeners, breaking the circular
# reference, and everything will get freed.
self._app.free_channel(self._channelid)
class AppNamespace: class AppNamespace:
def __init__(self, db, welcome, appid): def __init__(self, db, welcome, appid):
self._db = db self._db = db
@ -237,7 +304,7 @@ class AppNamespace:
def get_allocated(self): def get_allocated(self):
db = self._db db = self._db
c = db.execute("SELECT DISTINCT `channelid` FROM `allocations`" c = db.execute("SELECT DISTINCT `channelid` FROM `messages`"
" WHERE `appid`=?", (self._appid,)) " WHERE `appid`=?", (self._appid,))
return set([row["channelid"] for row in c.fetchall()]) return set([row["channelid"] for row in c.fetchall()])
@ -258,10 +325,9 @@ class AppNamespace:
raise ValueError("unable to find a free channel-id") raise ValueError("unable to find a free channel-id")
def allocate_channel(self, channelid, side): def allocate_channel(self, channelid, side):
db = self._db channel = self.get_channel(channelid)
db.execute("INSERT INTO `allocations` VALUES (?,?,?)", channel.allocate(side)
(self._appid, channelid, side)) return channel
db.commit()
def get_channel(self, channelid): def get_channel(self, channelid):
assert isinstance(channelid, int) assert isinstance(channelid, int)
@ -271,46 +337,28 @@ class AppNamespace:
self._appid, channelid) self._appid, channelid)
return self._channels[channelid] return self._channels[channelid]
def maybe_free_child(self, channelid, side): def free_channel(self, channelid):
db = self._db # called from Channel.delete_and_summarize(), which deletes any
db.execute("DELETE FROM `allocations`" # messages
" WHERE `appid`=? AND `channelid`=? AND `side`=?",
(self._appid, channelid, side))
db.commit()
remaining = db.execute("SELECT COUNT(*) FROM `allocations`"
" WHERE `appid`=? AND `channelid`=?",
(self._appid, channelid)).fetchone()[0]
if remaining:
return False
self._free_child(channelid)
return True
def _free_child(self, channelid):
db = self._db
db.execute("DELETE FROM `allocations`"
" WHERE `appid`=? AND `channelid`=?",
(self._appid, channelid))
db.execute("DELETE FROM `messages`"
" WHERE `appid`=? AND `channelid`=?",
(self._appid, channelid))
db.commit()
if channelid in self._channels: if channelid in self._channels:
self._channels.pop(channelid) self._channels.pop(channelid)
log.msg("freed+killed #%d, now have %d DB channels, %d live" % log.msg("freed+killed #%d, now have %d DB channels, %d live" %
(channelid, len(self.get_allocated()), len(self._channels))) (channelid, len(self.get_allocated()), len(self._channels)))
def prune_old_channels(self): def prune_old_channels(self):
db = self._db log.msg(" channel prune begins")
old = time.time() - CHANNEL_EXPIRATION_TIME # a channel is deleted when there are no listeners and there have
for channelid in self.get_allocated(): # been no messages added in CHANNEL_EXPIRATION_TIME seconds
c = db.execute("SELECT `when` FROM `messages`" channels = set(self.get_allocated()) # these have messages
" WHERE `appid`=? AND `channelid`=?" channels.update(self._channels) # these might have listeners
" ORDER BY `when` DESC LIMIT 1", for channelid in channels:
(self._appid, channelid)) log.msg(" channel prune checking %d" % channelid)
rows = c.fetchall() channel = self.get_channel(channelid)
if not rows or (rows[0]["when"] < old): if channel.is_idle():
log.msg("expiring %d" % channelid) log.msg(" channel prune expiring %d" % channelid)
self._free_child(channelid) channel.delete_and_summarize() # calls self.free_channel
log.msg(" channel prune done, %r left" % (self._channels.keys(),))
return bool(self._channels) return bool(self._channels)
class Relay(resource.Resource, service.MultiService): class Relay(resource.Resource, service.MultiService):
@ -345,7 +393,14 @@ class Relay(resource.Resource, service.MultiService):
return self._apps[appid] return self._apps[appid]
def prune(self): def prune(self):
for appid in list(self._apps): log.msg("beginning app prune")
still_active = self._apps[appid].prune_old_channels() c = self._db.execute("SELECT DISTINCT `appid` FROM `messages`")
apps = set([row["appid"] for row in c.fetchall()]) # these have messages
apps.update(self._apps) # these might have listeners
for appid in apps:
log.msg(" app prune checking %r" % (appid,))
still_active = self.get_app(appid).prune_old_channels()
if not still_active: if not still_active:
log.msg("prune pops app %r" % (appid,))
self._apps.pop(appid) self._apps.pop(appid)
log.msg("app prune ends, %d remaining apps" % len(self._apps))