From 41f229de8798910a8880d38920b9d354831ede00 Mon Sep 17 00:00:00 2001 From: Brian Warner Date: Thu, 23 Jun 2016 19:31:18 -0700 Subject: [PATCH] use 'mailbox_sides' instead of cols in 'mailboxes' --- src/wormhole/server/db-schemas/v3.sql | 20 ++- src/wormhole/server/rendezvous.py | 206 ++++++++++--------------- src/wormhole/test/test_server.py | 207 +++++++++++--------------- 3 files changed, 179 insertions(+), 254 deletions(-) diff --git a/src/wormhole/server/db-schemas/v3.sql b/src/wormhole/server/db-schemas/v3.sql index 5ee66c0..31fbaf4 100644 --- a/src/wormhole/server/db-schemas/v3.sql +++ b/src/wormhole/server/db-schemas/v3.sql @@ -36,20 +36,24 @@ CREATE TABLE `nameplate_sides` -- Clients exchange messages through a "mailbox", which has a long (randomly -- unique) identifier and a queue of messages. +-- `id` is randomly-generated and unique across all apps. CREATE TABLE `mailboxes` ( `app_id` VARCHAR, - `id` VARCHAR, - `side1` VARCHAR, -- side name, or NULL - `side2` VARCHAR, -- side name, or NULL - `crowded` BOOLEAN, -- at some point, three or more sides were involved - `first_mood` VARCHAR, - -- timing data for the mailbox itself - `started` INTEGER, -- time when opened - `second` INTEGER -- time when second side opened + `id` VARCHAR PRIMARY KEY, + `updated` INTEGER -- time of last activity, used for pruning ); CREATE INDEX `mailboxes_idx` ON `mailboxes` (`app_id`, `id`); +CREATE TABLE `mailbox_sides` +( + `mailbox_id` REFERENCES `mailboxes`(`id`), + `opened` BOOLEAN, -- True after open(), False after close() + `side` VARCHAR, + `added` INTEGER, -- time when this side first claimed the nameplate + `mood` VARCHAR +); + CREATE TABLE `messages` ( `app_id` VARCHAR, diff --git a/src/wormhole/server/rendezvous.py b/src/wormhole/server/rendezvous.py index 5df1d33..45b78f4 100644 --- a/src/wormhole/server/rendezvous.py +++ b/src/wormhole/server/rendezvous.py @@ -16,33 +16,9 @@ EXPIRATION_CHECK_PERIOD = 1*HOUR def generate_mailbox_id(): return base64.b32encode(os.urandom(8)).lower().strip(b"=").decode("ascii") - -SideResult = namedtuple("SideResult", ["changed", "empty", "side1", "side2"]) -Unchanged = SideResult(changed=False, empty=False, side1=None, side2=None) class CrowdedError(Exception): pass -def add_side(row, new_side): - old_sides = [s for s in [row["side1"], row["side2"]] if s] - assert old_sides - if new_side in old_sides: - return Unchanged - if len(old_sides) == 2: - raise CrowdedError("too many sides for this thing") - return SideResult(changed=True, empty=False, - side1=old_sides[0], side2=new_side) - -def remove_side(row, side): - old_sides = [s for s in [row["side1"], row["side2"]] if s] - if side not in old_sides: - return Unchanged - remaining_sides = old_sides[:] - remaining_sides.remove(side) - if remaining_sides: - return SideResult(changed=True, empty=False, side1=remaining_sides[0], - side2=None) - return SideResult(changed=True, empty=True, side1=None, side2=None) - Usage = namedtuple("Usage", ["started", "waiting_time", "total_time", "result"]) TransitUsage = namedtuple("TransitUsage", ["started", "waiting_time", "total_time", @@ -65,23 +41,24 @@ class Mailbox: # requires caller to db.commit() assert isinstance(side, type("")), type(side) db = self._db - row = db.execute("SELECT * FROM `mailboxes`" - " WHERE `app_id`=? AND `id`=?", - (self._app_id, self._mailbox_id)).fetchone() - try: - sr = add_side(row, side) - except CrowdedError: - db.execute("UPDATE `mailboxes` SET `crowded`=?" - " WHERE `app_id`=? AND `id`=?", - (True, self._app_id, self._mailbox_id)) - db.commit() - raise - if sr.changed: - db.execute("UPDATE `mailboxes` SET" - " `side1`=?, `side2`=?, `second`=?" - " WHERE `app_id`=? AND `id`=?", - (sr.side1, sr.side2, when, - self._app_id, self._mailbox_id)) + + already = db.execute("SELECT * FROM `mailbox_sides`" + " WHERE `mailbox_id`=? AND `side`=?", + (self._mailbox_id, side)).fetchone() + if not already: + db.execute("INSERT INTO `mailbox_sides`" + " (`mailbox_id`, `opened`, `side`, `added`)" + " VALUES(?,?,?,?)", + (self._mailbox_id, True, side, when)) + db.execute("UPDATE `mailboxes` SET `updated`=? WHERE `id`=?", + (when, self._mailbox_id)) + db.commit() # XXX: reconcile the need for this with the comment above + + rows = db.execute("SELECT * FROM `mailbox_sides`" + " WHERE `mailbox_id`=?", + (self._mailbox_id,)).fetchall() + if len(rows) > 2: + raise CrowdedError("too many sides have opened this mailbox") def get_messages(self): messages = [] @@ -118,6 +95,8 @@ class Mailbox: " VALUES (?,?,?,?,?, ?,?)", (self._app_id, self._mailbox_id, sm.side, sm.phase, sm.body, sm.server_rx, sm.msg_id)) + self._db.execute("UPDATE `mailboxes` SET `updated`=? WHERE `id`=?", + (sm.server_rx, self._mailbox_id)) self._db.commit() def add_message(self, sm): @@ -133,34 +112,36 @@ class Mailbox: (self._app_id, self._mailbox_id)).fetchone() if not row: return - sr = remove_side(row, side) - if sr.empty: - self._app._summarize_mailbox_and_store(self._mailbox_id, row, - mood, when, pruned=False) - self._delete() - db.commit() - elif sr.changed: - db.execute("UPDATE `mailboxes`" - " SET `side1`=?, `side2`=?, `first_mood`=?" - " WHERE `app_id`=? AND `id`=?", - (sr.side1, sr.side2, mood, - self._app_id, self._mailbox_id)) - db.commit() - def _delete(self): - # requires caller to db.commit() - self._db.execute("DELETE FROM `mailboxes`" - " WHERE `app_id`=? AND `id`=?", - (self._app_id, self._mailbox_id)) - self._db.execute("DELETE FROM `messages`" - " WHERE `app_id`=? AND `mailbox_id`=?", - (self._app_id, self._mailbox_id)) + row = db.execute("SELECT * FROM `mailbox_sides`" + " WHERE `mailbox_id`=? AND `side`=?", + (self._mailbox_id, side)).fetchone() + if not row: + return + db.execute("UPDATE `mailbox_sides` SET `opened`=?, `mood`=?" + " WHERE `mailbox_id`=? AND `side`=?", + (False, mood, self._mailbox_id, side)) + db.commit() + # are any sides still open? + side_rows = db.execute("SELECT * FROM `mailbox_sides`" + " WHERE `mailbox_id`=?", + (self._mailbox_id,)).fetchall() + if any([sr["opened"] for sr in side_rows]): + return + + # nope. delete and summarize + db.execute("DELETE FROM `messages` WHERE `mailbox_id`=?", + (self._mailbox_id,)) + db.execute("DELETE FROM `mailbox_sides` WHERE `mailbox_id`=?", + (self._mailbox_id,)) + db.execute("DELETE FROM `mailboxes` WHERE `id`=?", (self._mailbox_id,)) + self._app._summarize_mailbox_and_store(side_rows, when, pruned=False) + db.commit() # Shut down any listeners, just in case they're still lingering # around. for (send_f, stop_f) in self._listeners.values(): stop_f() - self._app.free_mailbox(self._mailbox_id) def is_active(self): @@ -260,6 +241,7 @@ class AppNamespace: db.execute("UPDATE `nameplates` SET `updated`=? WHERE `id`=?", (when, npid)) db.commit() + rows = db.execute("SELECT * FROM `nameplate_sides`" " WHERE `nameplates_id`=?", (npid,)).fetchall() if len(rows) > 2: @@ -343,11 +325,11 @@ class AppNamespace: log.msg("spawning #%s for app_id %s" % (mailbox_id, self._app_id)) db.execute("INSERT INTO `mailboxes`" - " (`app_id`, `id`, `side1`, `crowded`, `started`)" - " VALUES(?,?,?,?,?)", - (self._app_id, mailbox_id, side, False, when)) - db.commit() # XXX - # mailbox.open() does a SELECT to find the old sides + " (`app_id`, `id`, `updated`)" + " VALUES(?,?,?)", + (self._app_id, mailbox_id, when)) + # we don't need a commit here, because mailbox.open() only + # does SELECT FROM `mailbox_sides`, not from `mailboxes` self._mailboxes[mailbox_id] = Mailbox(self, self._db, self._app_id, mailbox_id) mailbox = self._mailboxes[mailbox_id] @@ -365,15 +347,9 @@ class AppNamespace: # log.msg("freed+killed #%s, now have %d DB mailboxes, %d live" % # (mailbox_id, len(self.get_claimed()), len(self._mailboxes))) - def _summarize_mailbox_and_store(self, mailbox_id, row, - second_mood, delete_time, pruned): + def _summarize_mailbox_and_store(self, side_rows, delete_time, pruned): db = self._db - rows = db.execute("SELECT DISTINCT(`side`) FROM `messages`" - " WHERE `app_id`=? AND `mailbox_id`=?", - (self._app_id, mailbox_id)).fetchall() - num_sides = len(rows) - u = self._summarize_mailbox(row, num_sides, second_mood, delete_time, - pruned) + u = self._summarize_mailbox(side_rows, delete_time, pruned) db.execute("INSERT INTO `mailbox_usage`" " (`app_id`," " `started`, `total_time`, `waiting_time`, `result`)" @@ -381,16 +357,17 @@ class AppNamespace: (self._app_id, u.started, u.total_time, u.waiting_time, u.result)) - def _summarize_mailbox(self, row, num_sides, second_mood, delete_time, - pruned): - started = row["started"] + def _summarize_mailbox(self, side_rows, delete_time, pruned): + times = sorted([row["added"] for row in side_rows]) + started = times[0] if self._blur_usage: started = self._blur_usage * (started // self._blur_usage) waiting_time = None - if row["second"]: - waiting_time = row["second"] - row["started"] - total_time = delete_time - row["started"] + if len(times) > 1: + waiting_time = times[1] - times[0] + total_time = delete_time - times[0] + num_sides = len(times) if num_sides == 0: result = "quiet" elif num_sides == 1: @@ -398,7 +375,8 @@ class AppNamespace: else: result = "happy" - moods = set([row["first_mood"], second_mood]) + # "mood" is only recorded at close() + moods = [row["mood"] for row in side_rows if row.get("mood")] if "lonely" in moods: result = "lonely" if "errory" in moods: @@ -407,7 +385,7 @@ class AppNamespace: result = "scary" if pruned: result = "pruney" - if row["crowded"]: + if num_sides > 2: result = "crowded" return Usage(started=started, waiting_time=waiting_time, @@ -425,47 +403,15 @@ class AppNamespace: # for all `mailboxes`: classify as new or old OLD = 0; NEW = 1 all_mailboxes = {} - all_mailbox_rows = {} - for row in db.execute("SELECT * FROM `mailboxes`" - " WHERE `app_id`=?", + for row in db.execute("SELECT * FROM `mailboxes` WHERE `app_id`=?", (self._app_id,)).fetchall(): mailbox_id = row["id"] - all_mailbox_rows[mailbox_id] = row - if row["started"] > old: - which = NEW - elif row["second"] and row["second"] > old: + if row["updated"] > old: which = NEW else: which = OLD all_mailboxes[mailbox_id] = which - #log.msg(" 2: all_mailboxes", all_mailboxes, all_mailbox_rows) - - # for all mailbox ids used by `messages`: - # if there is no matching mailbox: delete the messages - # if there is at least one new message (select when>old limit 1): - # classify the mailbox as new - for row in db.execute("SELECT DISTINCT(`mailbox_id`)" - " FROM `messages`" - " WHERE `app_id`=?", - (self._app_id,)).fetchall(): - mailbox_id = row["mailbox_id"] - if mailbox_id not in all_mailboxes: - log.msg(" deleting orphan messages", mailbox_id) - db.execute("DELETE FROM `messages`" - " WHERE `app_id`=? AND `mailbox_id`=?", - (self._app_id, mailbox_id)) - modified = True - else: - new_msgs = db.execute("SELECT * FROM `messages`" - " WHERE `app_id`=? AND `mailbox_id`=?" - " AND `server_rx` > ?" - " LIMIT 1", - (self._app_id, mailbox_id, old) - ).fetchall() - if new_msgs: - #log.msg(" 3-: saved by new messages", new_msgs) - all_mailboxes[mailbox_id] = NEW - #log.msg(" 4: all_mailboxes", all_mailboxes) + #log.msg(" 2: all_mailboxes", all_mailboxes) # for all mailbox objects with active listeners: # classify the mailbox as new @@ -484,8 +430,7 @@ class AppNamespace: # if the nameplate is new: # classify mailbox as new old_nameplate_ids = [] - for row in db.execute("SELECT * FROM `nameplates`" - " WHERE `app_id`=?", + for row in db.execute("SELECT * FROM `nameplates` WHERE `app_id`=?", (self._app_id,)).fetchall(): npid = row["id"] if row["updated"] > old: @@ -523,15 +468,16 @@ class AppNamespace: for mailbox_id, which in all_mailboxes.items(): if which == OLD: log.msg(" deleting mailbox", mailbox_id) - self._summarize_mailbox_and_store(mailbox_id, - all_mailbox_rows[mailbox_id], - "pruney", now, pruned=True) - db.execute("DELETE FROM `messages`" - " WHERE `app_id`=? AND `mailbox_id`=?", - (self._app_id, mailbox_id)) - db.execute("DELETE FROM `mailboxes`" - " WHERE `app_id`=? AND `id`=?", - (self._app_id, mailbox_id)) + side_rows = db.execute("SELECT * FROM `mailbox_sides`" + " WHERE `mailbox_id`=?", + (mailbox_id,)).fetchall() + db.execute("DELETE FROM `messages` WHERE `mailbox_id`=?", + (mailbox_id,)) + db.execute("DELETE FROM `mailbox_sides` WHERE `mailbox_id`=?", + (mailbox_id,)) + db.execute("DELETE FROM `mailboxes` WHERE `id`=?", + (mailbox_id,)) + self._summarize_mailbox_and_store(side_rows, now, pruned=True) modified = True if modified: diff --git a/src/wormhole/test/test_server.py b/src/wormhole/test/test_server.py index 0d6b286..f5ac71b 100644 --- a/src/wormhole/test/test_server.py +++ b/src/wormhole/test/test_server.py @@ -12,7 +12,7 @@ from autobahn.twisted import websocket from .. import __version__ from .common import ServerBase from ..server import rendezvous, transit_server -from ..server.rendezvous import Usage, SidedMessage, Mailbox +from ..server.rendezvous import Usage, SidedMessage from ..server.database import get_db class Server(ServerBase, unittest.TestCase): @@ -152,77 +152,69 @@ class Server(ServerBase, unittest.TestCase): def _mailbox(self, app, mailbox_id): - return app._db.execute("SELECT * FROM `mailboxes`" - " WHERE `app_id`='appid' AND `id`=?", - (mailbox_id,)).fetchone() + mb_row = app._db.execute("SELECT * FROM `mailboxes`" + " WHERE `app_id`='appid' AND `id`=?", + (mailbox_id,)).fetchone() + if not mb_row: + return None, None + side_rows = app._db.execute("SELECT * FROM `mailbox_sides`" + " WHERE `mailbox_id`=?", + (mailbox_id,)).fetchall() + return mb_row, side_rows def test_mailbox(self): app = self._rendezvous.get_app("appid") mailbox_id = "mid" m1 = app.open_mailbox(mailbox_id, "side1", 0) - row = self._mailbox(app, mailbox_id) - self.assertEqual(row["side1"], "side1") - self.assertEqual(row["side2"], None) - self.assertEqual(row["crowded"], False) - self.assertEqual(row["started"], 0) - self.assertEqual(row["second"], None) + mb_row, side_rows = self._mailbox(app, mailbox_id) + self.assertEqual(len(side_rows), 1) + self.assertEqual(side_rows[0]["side"], "side1") + self.assertEqual(side_rows[0]["added"], 0) # opening the same mailbox twice, by the same side, gets the same - # object + # object, and does not update the "added" timestamp self.assertIdentical(m1, app.open_mailbox(mailbox_id, "side1", 1)) - row = self._mailbox(app, mailbox_id) - self.assertEqual(row["side1"], "side1") - self.assertEqual(row["side2"], None) - self.assertEqual(row["crowded"], False) - self.assertEqual(row["started"], 0) - self.assertEqual(row["second"], None) + mb_row, side_rows = self._mailbox(app, mailbox_id) + self.assertEqual(len(side_rows), 1) + self.assertEqual(side_rows[0]["side"], "side1") + self.assertEqual(side_rows[0]["added"], 0) # opening a second side gets the same object, and adds a new claim self.assertIdentical(m1, app.open_mailbox(mailbox_id, "side2", 2)) - row = self._mailbox(app, mailbox_id) - self.assertEqual(row["side1"], "side1") - self.assertEqual(row["side2"], "side2") - self.assertEqual(row["crowded"], False) - self.assertEqual(row["started"], 0) - self.assertEqual(row["second"], 2) + mb_row, side_rows = self._mailbox(app, mailbox_id) + self.assertEqual(len(side_rows), 2) + adds = [(row["side"], row["added"]) for row in side_rows] + self.assertIn(("side1", 0), adds) + self.assertIn(("side2", 2), adds) # a third open marks it as crowded self.assertRaises(rendezvous.CrowdedError, app.open_mailbox, mailbox_id, "side3", 3) - row = self._mailbox(app, mailbox_id) - self.assertEqual(row["side1"], "side1") - self.assertEqual(row["side2"], "side2") - self.assertEqual(row["crowded"], True) - self.assertEqual(row["started"], 0) - self.assertEqual(row["second"], 2) + mb_row, side_rows = self._mailbox(app, mailbox_id) + self.assertEqual(len(side_rows), 3) + m1.close("side3", "company", 4) # closing a side that never claimed the mailbox is ignored m1.close("side4", "mood", 4) - row = self._mailbox(app, mailbox_id) - self.assertEqual(row["side1"], "side1") - self.assertEqual(row["side2"], "side2") - self.assertEqual(row["crowded"], True) - self.assertEqual(row["started"], 0) - self.assertEqual(row["second"], 2) + mb_row, side_rows = self._mailbox(app, mailbox_id) + self.assertEqual(len(side_rows), 3) # closing one side leaves the second claim m1.close("side1", "mood", 5) - row = self._mailbox(app, mailbox_id) - self.assertEqual(row["side1"], "side2") - self.assertEqual(row["side2"], None) - self.assertEqual(row["crowded"], True) - self.assertEqual(row["started"], 0) - self.assertEqual(row["second"], 2) + mb_row, side_rows = self._mailbox(app, mailbox_id) + sides = [(row["side"], row["opened"], row["mood"]) for row in side_rows] + self.assertIn(("side1", False, "mood"), sides) + self.assertIn(("side2", True, None), sides) + self.assertIn(("side3", False, "company"), sides) - # closing one side multiple is ignored + # closing one side multiple times is ignored m1.close("side1", "mood", 6) - row = self._mailbox(app, mailbox_id) - self.assertEqual(row["side1"], "side2") - self.assertEqual(row["side2"], None) - self.assertEqual(row["crowded"], True) - self.assertEqual(row["started"], 0) - self.assertEqual(row["second"], 2) + mb_row, side_rows = self._mailbox(app, mailbox_id) + sides = [(row["side"], row["opened"], row["mood"]) for row in side_rows] + self.assertIn(("side1", False, "mood"), sides) + self.assertIn(("side2", True, None), sides) + self.assertIn(("side3", False, "company"), sides) l1 = []; stop1 = []; stop1_f = lambda: stop1.append(True) m1.add_listener("handle1", l1.append, stop1_f) @@ -231,8 +223,8 @@ class Server(ServerBase, unittest.TestCase): m1.close("side2", "mood", 7) self.assertEqual(stop1, [True]) - row = self._mailbox(app, mailbox_id) - self.assertEqual(row, None) + mb_row, side_rows = self._mailbox(app, mailbox_id) + self.assertEqual(mb_row, None) usage = app._db.execute("SELECT * FROM `mailbox_usage`").fetchone() self.assertEqual(usage["app_id"], "appid") self.assertEqual(usage["started"], 0) @@ -384,22 +376,20 @@ class Prune(unittest.TestCase): def test_lots(self): OLD = "old"; NEW = "new" for nameplate in [None, OLD, NEW]: - for mailbox in [None, OLD, NEW]: + for mailbox in [OLD, NEW]: listeners = [False] if mailbox is not None: listeners = [False, True] for has_listeners in listeners: - for messages in [None, OLD, NEW]: - self.one(nameplate, mailbox, has_listeners, messages) + self.one(nameplate, mailbox, has_listeners) - # def test_one(self): - # # to debug specific problems found by test_lots - # self.one(None, "old", False, 'new') + def test_one(self): + # to debug specific problems found by test_lots + self.one(None, "new", False) - def one(self, nameplate, mailbox, has_listeners, messages): - desc = ("nameplate=%s, mailbox=%s, has_listeners=%s," - " messages=%s" % - (nameplate, mailbox, has_listeners, messages)) + def one(self, nameplate, mailbox, has_listeners): + desc = ("nameplate=%s, mailbox=%s, has_listeners=%s" % + (nameplate, mailbox, has_listeners)) log.msg(desc) db = get_db(":memory:") @@ -412,46 +402,27 @@ class Prune(unittest.TestCase): when = {OLD: 1, NEW: 60} nameplate_survives = False mailbox_survives = False - messages_survive = False mbid = "mbid" if nameplate is not None: app.claim_nameplate("npid", "side1", when[nameplate], _test_mailbox_id=mbid) - if mailbox is not None: - mb = app.open_mailbox(mbid, "side1", when[mailbox]) - else: - # We might want a Mailbox, because that's the easiest way to add - # a "messages" row, but we can't use app.open_mailbox() because - # that modifies both the "mailboxes" table and app._mailboxes, - # and sometimes we're testing what happens when there are - # messages but not a mailbox - mb = Mailbox(app, db, APPID, mbid) - # we need app._mailboxes to know about this, because that's - # where it looks to find listeners - app._mailboxes[mbid] = mb + mb = app.open_mailbox(mbid, "side1", when[mailbox]) - if messages is not None: - sm = SidedMessage("side1", "phase", "body", when[messages], - "msgid") - mb.add_message(sm) + # the pruning algorithm doesn't care about the age of messages, + # because mailbox.updated is always updated each time we add a + # message + sm = SidedMessage("side1", "phase", "body", when[mailbox], "msgid") + mb.add_message(sm) if has_listeners: mb.add_listener("handle", None, None) - if mailbox is None and messages is not None: - # orphaned messages, even new ones, can't keep a nameplate alive - messages = None - messages_survive = False - - if (nameplate == NEW or mailbox == NEW - or has_listeners or messages == NEW): + if (nameplate == NEW or mailbox == NEW or has_listeners): if nameplate is not None: nameplate_survives = True - if mailbox is not None: - mailbox_survives = True - if messages is not None: - messages_survive = True + mailbox_survives = True + messages_survive = mailbox_survives rv.prune(now=123, old=50) @@ -931,41 +902,45 @@ class Summary(unittest.TestCase): def test_mailbox(self): app = rendezvous.AppNamespace(None, None, False, None) # starts at time 1, maybe gets second open at time 3, closes at 5 - base_row = {"started": 1, "second": None, - "first_mood": None, "crowded": False} - def summ(num_sides, second_mood=None, pruned=False, **kwargs): - row = base_row.copy() - row.update(kwargs) - return app._summarize_mailbox(row, num_sides, second_mood, 5, - pruned) + def s(rows, pruned=False): + return app._summarize_mailbox(rows, 5, pruned) - self.assertEqual(summ(1), Usage(1, None, 4, "lonely")) - self.assertEqual(summ(1, "lonely"), Usage(1, None, 4, "lonely")) - self.assertEqual(summ(1, "errory"), Usage(1, None, 4, "errory")) - self.assertEqual(summ(1, crowded=True), Usage(1, None, 4, "crowded")) + rows = [dict(added=1)] + self.assertEqual(s(rows), Usage(1, None, 4, "lonely")) + rows = [dict(added=1, mood="lonely")] + self.assertEqual(s(rows), Usage(1, None, 4, "lonely")) + rows = [dict(added=1, mood="errory")] + self.assertEqual(s(rows), Usage(1, None, 4, "errory")) + rows = [dict(added=1, mood=None)] + self.assertEqual(s(rows, pruned=True), Usage(1, None, 4, "pruney")) + rows = [dict(added=1, mood="happy")] + self.assertEqual(s(rows, pruned=True), Usage(1, None, 4, "pruney")) - self.assertEqual(summ(2, first_mood="happy", - second=3, second_mood="happy"), - Usage(1, 2, 4, "happy")) + rows = [dict(added=1, mood="happy"), dict(added=3, mood="happy")] + self.assertEqual(s(rows), Usage(1, 2, 4, "happy")) - self.assertEqual(summ(2, first_mood="errory", - second=3, second_mood="happy"), - Usage(1, 2, 4, "errory")) + rows = [dict(added=1, mood="errory"), dict(added=3, mood="happy")] + self.assertEqual(s(rows), Usage(1, 2, 4, "errory")) - self.assertEqual(summ(2, first_mood="happy", - second=3, second_mood="errory"), - Usage(1, 2, 4, "errory")) + rows = [dict(added=1, mood="happy"), dict(added=3, mood="errory")] + self.assertEqual(s(rows), Usage(1, 2, 4, "errory")) - self.assertEqual(summ(2, first_mood="scary", - second=3, second_mood="happy"), - Usage(1, 2, 4, "scary")) + rows = [dict(added=1, mood="scary"), dict(added=3, mood="happy")] + self.assertEqual(s(rows), Usage(1, 2, 4, "scary")) - self.assertEqual(summ(2, first_mood="scary", - second=3, second_mood="errory"), - Usage(1, 2, 4, "scary")) + rows = [dict(added=1, mood="scary"), dict(added=3, mood="errory")] + self.assertEqual(s(rows), Usage(1, 2, 4, "scary")) - self.assertEqual(summ(2, first_mood="happy", second=3, pruned=True), - Usage(1, 2, 4, "pruney")) + rows = [dict(added=1, mood="happy"), dict(added=3, mood=None)] + self.assertEqual(s(rows, pruned=True), Usage(1, 2, 4, "pruney")) + rows = [dict(added=1, mood="happy"), dict(added=3, mood="happy")] + self.assertEqual(s(rows, pruned=True), Usage(1, 2, 4, "pruney")) + + rows = [dict(added=1), dict(added=3), dict(added=4)] + self.assertEqual(s(rows), Usage(1, 2, 4, "crowded")) + + rows = [dict(added=1), dict(added=3), dict(added=4)] + self.assertEqual(s(rows, pruned=True), Usage(1, 2, 4, "crowded")) def test_nameplate(self): a = rendezvous.AppNamespace(None, None, False, None)