Repository: buildbot/buildbot

Issue:
Update docs for buildbot-worker
`master-worker.rst` was updated to meet my expectations from master/worker protocol — please review it carefully.

Some steps in documentation will not work immediately (e.g. `pip install buildbot-worker`), since `buildbot-worker` is not released yet (only as beta, which is not installed by default by pip).
I think this is OK for latest documentation.



Discussion:

Resolving patch:
diff --git a/master/buildbot/process/builder.py b/master/buildbot/process/builder.py
--- a/master/buildbot/process/builder.py
+++ b/master/buildbot/process/builder.py
@@ -82,6 +82,7 @@ def reconfigService(self, new_config):
         self.config = builder_config
 
         self.builder_status.setSlavenames(self.config.slavenames)
+        self.builder_status.setCacheSize(new_config.caches['Builds'])
 
         return defer.succeed(None)
 
@@ -756,4 +757,3 @@ def _gatherPingResults(self, res):
             if not success:
                 return False
         return True
-
diff --git a/master/buildbot/status/builder.py b/master/buildbot/status/builder.py
--- a/master/buildbot/status/builder.py
+++ b/master/buildbot/status/builder.py
@@ -25,6 +25,7 @@
 from twisted.persisted import styles
 from buildbot.process import metrics
 from buildbot import interfaces, util
+from buildbot.util.lru import LRUCache
 from buildbot.status.event import Event
 from buildbot.status.build import BuildStatus
 from buildbot.status.buildrequest import BuildRequestStatus
@@ -78,8 +79,7 @@ def __init__(self, buildername, category, master):
         self.currentBuilds = []
         self.nextBuild = None
         self.watchers = []
-        self.buildCache = weakref.WeakValueDictionary()
-        self.buildCache_LRU = []
+        self.buildCache = LRUCache(self.cacheMiss)
 
     # persistence
 
@@ -91,7 +91,6 @@ def __getstate__(self):
         d = styles.Versioned.__getstate__(self)
         d['watchers'] = []
         del d['buildCache']
-        del d['buildCache_LRU']
         for b in self.currentBuilds:
             b.saveYourself()
             # TODO: push a 'hey, build was interrupted' event
@@ -108,8 +107,7 @@ def __setstate__(self, d):
         # when loading, re-initialize the transient stuff. Remember that
         # upgradeToVersion1 and such will be called after this finishes.
         styles.Versioned.__setstate__(self, d)
-        self.buildCache = weakref.WeakValueDictionary()
-        self.buildCache_LRU = []
+        self.buildCache = LRUCache(self.cacheMiss)
         self.currentBuilds = []
         self.watchers = []
         self.slavenames = []
@@ -158,37 +156,19 @@ def saveYourself(self):
         except:
             log.msg("unable to save builder %s" % self.name)
             log.err()
-        
 
     # build cache management
 
+    def setCacheSize(self, size):
+        self.buildCache.set_max_size(size)
+
     def makeBuildFilename(self, number):
         return os.path.join(self.basedir, "%d" % number)
 
-    def touchBuildCache(self, build):
-        self.buildCache[build.number] = build
-        if build in self.buildCache_LRU:
-            self.buildCache_LRU.remove(build)
-        cache_size = self.master.config.caches['Builds']
-        self.buildCache_LRU = self.buildCache_LRU[-(cache_size-1):] + [ build ]
-        return build
-
     def getBuildByNumber(self, number):
-        # first look in currentBuilds
-        for b in self.currentBuilds:
-            if b.number == number:
-                return self.touchBuildCache(b)
-
-        # then in the buildCache
-        try:
-            b = self.buildCache[number]
-        except KeyError:
-            metrics.MetricCountEvent.log("buildCache.misses", 1)
-        else:
-            metrics.MetricCountEvent.log("buildCache.hits", 1)
-            return self.touchBuildCache(b)
+        return self.buildCache.get(number)
 
-        # then fall back to loading it from disk
+    def loadBuildFromFile(self, number):
         filename = self.makeBuildFilename(number)
         try:
             log.msg("Loading builder %s's build %d from on-disk pickle"
@@ -210,12 +190,26 @@ def getBuildByNumber(self, number):
 
             # check that logfiles exist
             build.checkLogfiles()
-            return self.touchBuildCache(build)
+            return build
         except IOError:
             raise IndexError("no such build %d" % number)
         except EOFError:
             raise IndexError("corrupted build pickle %d" % number)
 
+    def cacheMiss(self, number, **kwargs):
+        # If kwargs['val'] exists, this is a new value being added to
+        # the cache.  Just return it.
+        if 'val' in kwargs:
+            return kwargs['val']
+
+        # first look in currentBuilds
+        for b in self.currentBuilds:
+            if b.number == number:
+                return b
+
+        # then fall back to loading it from disk
+        return self.loadBuildFromFile(number)
+
     def prune(self, events_only=False):
         # begin by pruning our own events
         eventHorizon = self.master.config.eventHorizon
@@ -263,7 +257,7 @@ def prune(self, events_only=False):
                     is_logfile = True
 
             if num is None: continue
-            if num in self.buildCache: continue
+            if num in self.buildCache.cache: continue
 
             if (is_logfile and num < earliest_log) or num < earliest_build:
                 pathname = os.path.join(self.basedir, filename)
@@ -485,7 +479,7 @@ def buildStarted(self, s):
         assert s.builder is self # paranoia
         assert s not in self.currentBuilds
         self.currentBuilds.append(s)
-        self.touchBuildCache(s)
+        self.buildCache.get(s.number, val=s)
 
         # now that the BuildStatus is prepared to answer queries, we can
         # announce the new build to all our watchers
diff --git a/master/buildbot/util/lru.py b/master/buildbot/util/lru.py
--- a/master/buildbot/util/lru.py
+++ b/master/buildbot/util/lru.py
@@ -20,71 +20,175 @@
 from collections import deque
 from buildbot.util.bbcollections import defaultdict
 
-class AsyncLRUCache(object):
 
-    __slots__ = ('max_size max_queue miss_fn '
-                 'queue cache weakrefs refcount concurrent '
-                 'hits refhits misses'.split())
+class LRUCache(object):
+    """
+    A least-recently-used cache, with a fixed maximum size.
+
+    See buildbot manual for more information.
+    """
+
+    __slots__ = ('max_size max_queue miss_fn queue cache weakrefs '
+                 'refcount hits refhits misses'.split())
     sentinel = object()
     QUEUE_SIZE_FACTOR = 10
 
     def __init__(self, miss_fn, max_size=50):
-        self.miss_fn = miss_fn
         self.max_size = max_size
         self.max_queue = max_size * self.QUEUE_SIZE_FACTOR
         self.queue = deque()
         self.cache = {}
         self.weakrefs = WeakValueDictionary()
-        self.concurrent = {}
         self.hits = self.misses = self.refhits = 0
         self.refcount = defaultdict(lambda : 0)
+        self.miss_fn = miss_fn
+
+    def put(self, key, value):
+        if key in self.cache:
+            self.cache[key] = value
+            self.weakrefs[key] = value
+        elif key in self.weakrefs:
+            self.weakrefs[key] = value
 
     def get(self, key, **miss_fn_kwargs):
+        try:
+            return self._get_hit(key)
+        except KeyError:
+            pass
+
+        self.misses += 1
+
+        result = self.miss_fn(key, **miss_fn_kwargs)
+        if result is not None:
+            self.cache[key] = result
+            self.weakrefs[key] = result
+            self._ref_key(key)
+            self._purge()
+
+        return result
+
+    def keys(self):
+        return self.cache.keys()
+
+    def set_max_size(self, max_size):
+        if self.max_size == max_size:
+            return
+
+        self.max_size = max_size
+        self.max_queue = max_size * self.QUEUE_SIZE_FACTOR
+        self._purge()
+
+    def inv(self):
+        global inv_failed
+
+        # the keys of the queue and cache should be identical
+        cache_keys = set(self.cache.keys())
+        queue_keys = set(self.queue)
+        if queue_keys - cache_keys:
+            log.msg("INV: uncached keys in queue:", queue_keys - cache_keys)
+            inv_failed = True
+        if cache_keys - queue_keys:
+            log.msg("INV: unqueued keys in cache:", cache_keys - queue_keys)
+            inv_failed = True
+
+        # refcount should always represent the number of times each key appears
+        # in the queue
+        exp_refcount = dict()
+        for k in self.queue:
+            exp_refcount[k] = exp_refcount.get(k, 0) + 1
+        if exp_refcount != self.refcount:
+            log.msg("INV: refcounts differ:")
+            log.msg(" expected:", sorted(exp_refcount.items()))
+            log.msg("      got:", sorted(self.refcount.items()))
+            inv_failed = True
+
+    def _ref_key(self, key):
+        """Record a reference to the argument key."""
+        queue = self.queue
+        refcount = self.refcount
+
+        queue.append(key)
+        refcount[key] = refcount[key] + 1
+
+        # periodically compact the queue by eliminating duplicate keys
+        # while preserving order of most recent access.  Note that this
+        # is only required when the cache does not exceed its maximum
+        # size
+        if len(queue) > self.max_queue:
+            refcount.clear()
+            queue_appendleft = queue.appendleft
+            queue_appendleft(self.sentinel)
+            for k in ifilterfalse(refcount.__contains__,
+                                    iter(queue.pop, self.sentinel)):
+                queue_appendleft(k)
+                refcount[k] = 1
+
+    def _get_hit(self, key):
+        """Try to do a value lookup from the existing cache entries."""
+        try:
+            result = self.cache[key]
+            self.hits += 1
+            self._ref_key(key)
+            return result
+        except KeyError:
+            pass
+
+        result = self.weakrefs[key]
+        self.refhits += 1
+        self.cache[key] = result
+        self._ref_key(key)
+        return result
+
+    def _purge(self):
+        """
+        Trim the cache down to max_size by evicting the
+        least-recently-used entries.
+        """
+        if len(self.cache) <= self.max_size:
+            return
+
         cache = self.cache
-        weakrefs = self.weakrefs
         refcount = self.refcount
-        concurrent = self.concurrent
         queue = self.queue
+        max_size = self.max_size
 
-        # utility function to record recent use of this key
-        def ref_key():
-            queue.append(key)
-            refcount[key] = refcount[key] + 1
-
-            # periodically compact the queue by eliminating duplicate keys
-            # while preserving order of most recent access.  Note that this
-            # is only required when the cache does not exceed its maximum
-            # size
-            if len(queue) > self.max_queue:
-                refcount.clear()
-                queue_appendleft = queue.appendleft
-                queue_appendleft(self.sentinel)
-                for k in ifilterfalse(refcount.__contains__,
-                                        iter(queue.pop, self.sentinel)):
-                    queue_appendleft(k)
-                    refcount[k] = 1
+        # purge least recently used entries, using refcount to count entries
+        # that appear multiple times in the queue
+        while len(cache) > max_size:
+            refc = 1
+            while refc:
+                k = queue.popleft()
+                refc = refcount[k] = refcount[k] - 1
+            del cache[k]
+            del refcount[k]
+
+
+class AsyncLRUCache(LRUCache):
+    """
+    An LRU cache with asynchronous locking to ensure that in the common case of
+    multiple concurrent requests for the same key, only one fetch is performed.
+    """
+
+    __slots__ = ['concurrent']
+
+    def __init__(self, miss_fn, max_size=50):
+        LRUCache.__init__(self, miss_fn, max_size=max_size)
+        self.concurrent = {}
 
+    def get(self, key, **miss_fn_kwargs):
         try:
-            result = cache[key]
-            self.hits += 1
-            ref_key()
+            result = self._get_hit(key)
             return defer.succeed(result)
         except KeyError:
-            try:
-                result = weakrefs[key]
-                self.refhits += 1
-                cache[key] = result
-                ref_key()
-                return defer.succeed(result)
-            except KeyError:
-                # if there's already a fetch going on, add
-                # to the list of waiting deferreds
-                conc = concurrent.get(key)
-                if conc:
-                    self.hits += 1
-                    d = defer.Deferred()
-                    conc.append(d)
-                    return d
+            pass
+
+        concurrent = self.concurrent
+        conc = concurrent.get(key)
+        if conc:
+            self.hits += 1
+            d = defer.Deferred()
+            conc.append(d)
+            return d
 
         # if we're here, we've missed and need to fetch
         self.misses += 1
@@ -98,15 +202,14 @@ def ref_key():
 
         def handle_result(result):
             if result is not None:
-                cache[key] = result
-                weakrefs[key] = result
+                self.cache[key] = result
+                self.weakrefs[key] = result
 
                 # reference the key once, possibly standing in for multiple
                 # concurrent accesses
-                ref_key()
+                self._ref_key(key)
 
-            self.inv()
-            self._purge()
+                self._purge()
 
             # and fire all of the waiting Deferreds
             dlist = concurrent.pop(key)
@@ -124,63 +227,6 @@ def handle_failure(f):
 
         return d
 
-    def _purge(self):
-        if len(self.cache) <= self.max_size:
-            return
-
-        cache = self.cache
-        refcount = self.refcount
-        queue = self.queue
-        max_size = self.max_size
-
-        # purge least recently used entries, using refcount to count entries
-        # that appear multiple times in the queue
-        while len(cache) > max_size:
-            refc = 1
-            while refc:
-                k = queue.popleft()
-                refc = refcount[k] = refcount[k] - 1
-            del cache[k]
-            del refcount[k]
-
-    def put(self, key, value):
-        if key in self.cache:
-            self.cache[key] = value
-            self.weakrefs[key] = value
-        elif key in self.weakrefs:
-            self.weakrefs[key] = value
-
-    def set_max_size(self, max_size):
-        if self.max_size == max_size:
-            return
-
-        self.max_size = max_size
-        self.max_queue = max_size * self.QUEUE_SIZE_FACTOR
-        self._purge()
-
-    def inv(self):
-        global inv_failed
-
-        # the keys of the queue and cache should be identical
-        cache_keys = set(self.cache.keys())
-        queue_keys = set(self.queue)
-        if queue_keys - cache_keys:
-            log.msg("INV: uncached keys in queue:", queue_keys - cache_keys)
-            inv_failed = True
-        if cache_keys - queue_keys:
-            log.msg("INV: unqueued keys in cache:", cache_keys - queue_keys)
-            inv_failed = True
-
-        # refcount should always represent the number of times each key appears
-        # in the queue
-        exp_refcount = dict()
-        for k in self.queue:
-            exp_refcount[k] = exp_refcount.get(k, 0) + 1
-        if exp_refcount != self.refcount:
-            log.msg("INV: refcounts differ:")
-            log.msg(" expected:", sorted(exp_refcount.items()))
-            log.msg("      got:", sorted(self.refcount.items()))
-            inv_failed = True
 
 # for tests
 inv_failed = False


Test patch:
diff --git a/master/buildbot/test/unit/test_status_builder_cache.py b/master/buildbot/test/unit/test_status_builder_cache.py
new file mode 100644
--- /dev/null
+++ b/master/buildbot/test/unit/test_status_builder_cache.py
@@ -0,0 +1,72 @@
+# This file is part of Buildbot.  Buildbot is free software: you can
+# redistribute it and/or modify it under the terms of the GNU General Public
+# License as published by the Free Software Foundation, version 2.
+#
+# This program is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+# details.
+#
+# You should have received a copy of the GNU General Public License along with
+# this program; if not, write to the Free Software Foundation, Inc., 51
+# Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+#
+# Copyright Buildbot Team Members
+
+import os
+from mock import Mock
+from twisted.trial import unittest
+from buildbot.status import builder, master
+from buildbot.test.fake import fakemaster
+
+class TestBuildStatus(unittest.TestCase):
+
+    # that buildstep.BuildStepStatus is never instantiated here should tell you
+    # that these classes are not well isolated!
+
+    def setupBuilder(self, buildername, category=None):
+        master = fakemaster.make_master()
+        b = builder.BuilderStatus(buildername=buildername, category=category, master=master)
+        # Awkwardly, Status sets this member variable.
+        b.basedir = os.path.abspath(self.mktemp())
+        os.mkdir(b.basedir)
+        # Otherwise, builder.nextBuildNumber is not defined.
+        b.determineNextBuildNumber()
+        # Must initialize these fields before pickling.
+        b.currentBigState = 'idle'
+        b.status = 'idle'
+        return b
+
+    def setupStatus(self, b):
+        m = Mock()
+        m.buildbotURL = 'http://buildbot:8010/'
+        m.basedir = '/basedir'
+        s = master.Status(m)
+        b.status = s
+        return s
+
+    def testBuildCache(self):
+        b = self.setupBuilder('builder_1')
+        builds = []
+        for i in xrange(5):
+            build = b.newBuild()
+            build.setProperty('propkey', 'propval%d' % i, 'test')
+            builds.append(build)
+            build.buildStarted(build)
+            build.buildFinished()
+        for build in builds:
+            build2 = b.getBuild(build.number)
+            self.assertTrue(build2)
+            self.assertEqual(build2.number, build.number)
+            self.assertEqual(build2.getProperty('propkey'),
+                             'propval%d' % build.number)
+        # Do another round, to make sure we're hitting the cache
+        hits = b.buildCache.hits
+        for build in builds:
+            build2 = b.getBuild(build.number)
+            self.assertTrue(build2)
+            self.assertEqual(build2.number, build.number)
+            self.assertEqual(build2.getProperty('propkey'),
+                             'propval%d' % build.number)
+            self.assertEqual(b.buildCache.hits, hits+1)
+            hits = hits + 1
diff --git a/master/buildbot/test/unit/test_util_lru.py b/master/buildbot/test/unit/test_util_lru.py
--- a/master/buildbot/test/unit/test_util_lru.py
+++ b/master/buildbot/test/unit/test_util_lru.py
@@ -27,7 +27,221 @@ def short(k):
 def long(k):
     return set([k.upper() * 6])
 
-class LRUCache(unittest.TestCase):
+class LRUCacheTest(unittest.TestCase):
+
+    def setUp(self):
+        lru.inv_failed = False
+        self.lru = lru.LRUCache(short, 3)
+
+    def tearDown(self):
+        self.assertFalse(lru.inv_failed, "invariant failed; see logs")
+
+    def check_result(self, r, exp, exp_hits=None, exp_misses=None,
+                            exp_refhits=None):
+        self.assertEqual(r, exp)
+        if exp_hits is not None:
+            self.assertEqual(self.lru.hits, exp_hits)
+        if exp_misses is not None:
+            self.assertEqual(self.lru.misses, exp_misses)
+        if exp_refhits is not None:
+            self.assertEqual(self.lru.refhits, exp_refhits)
+
+    def test_single_key(self):
+        # just get an item
+        val = self.lru.get('a')
+        self.check_result(val, short('a'), 0, 1)
+
+        # second time, it should be cached..
+        self.lru.miss_fn = long
+        val = self.lru.get('a')
+        self.check_result(val, short('a'), 1, 1)
+
+    def test_simple_lru_expulsion(self):
+        val = self.lru.get('a')
+        self.check_result(val, short('a'), 0, 1)
+        val = self.lru.get('b')
+        self.check_result(val, short('b'), 0, 2)
+        val = self.lru.get('c')
+        self.check_result(val, short('c'), 0, 3)
+        val = self.lru.get('d')
+        self.check_result(val, short('d'), 0, 4)
+        del(val)
+        gc.collect()
+
+        # now try 'a' again - it should be a miss
+        self.lru.miss_fn = long
+        val = self.lru.get('a')
+        self.check_result(val, long('a'), 0, 5)
+
+        # ..and that expelled B, but C is still in the cache
+        val = self.lru.get('c')
+        self.check_result(val, short('c'), 1, 5)
+
+    def test_simple_lru_expulsion_maxsize_1(self):
+        self.lru = lru.LRUCache(short, 1)
+        val = self.lru.get('a')
+        self.check_result(val, short('a'), 0, 1)
+        val = self.lru.get('a')
+        self.check_result(val, short('a'), 1, 1)
+        val = self.lru.get('b')
+        self.check_result(val, short('b'), 1, 2)
+        del(val)
+        gc.collect()
+
+        # now try 'a' again - it should be a miss
+        self.lru.miss_fn = long
+        val = self.lru.get('a')
+        self.check_result(val, long('a'), 1, 3)
+        del(val)
+        gc.collect()
+
+        # ..and that expelled B
+        val = self.lru.get('b')
+        self.check_result(val, long('b'), 1, 4)
+
+    def test_simple_lru_expulsion_maxsize_1_null_result(self):
+        # a regression test for #2011
+        def miss_fn(k):
+            if k == 'b':
+                return None
+            else:
+                return short(k)
+        self.lru = lru.LRUCache(miss_fn, 1)
+        val = self.lru.get('a')
+        self.check_result(val, short('a'), 0, 1)
+        val = self.lru.get('b')
+        self.check_result(val, None, 0, 2)
+        del(val)
+
+        # 'a' was not expelled since 'b' was None
+        self.lru.miss_fn = long
+        val = self.lru.get('a')
+        self.check_result(val, short('a'), 1, 2)
+
+    def test_queue_collapsing(self):
+        # just to check that we're practicing with the right queue size (so
+        # QUEUE_SIZE_FACTOR is 10)
+        self.assertEqual(self.lru.max_queue, 30)
+
+        for c in 'a' + 'x' * 27 + 'ab':
+            res = self.lru.get(c)
+        self.check_result(res, short('b'), 27, 3)
+
+        # at this point, we should have 'x', 'a', and 'b' in the cache, and
+        # 'axx..xxab' in the queue.
+        self.assertEqual(len(self.lru.queue), 30)
+
+        # This 'get' operation for an existing key should cause compaction
+        res = self.lru.get('b')
+        self.check_result(res, short('b'), 28, 3)
+
+        self.assertEqual(len(self.lru.queue), 3)
+
+        # expect a cached short('a')
+        self.lru.miss_fn = long
+        res = self.lru.get('a')
+        self.check_result(res, short('a'), 29, 3)
+
+    def test_all_misses(self):
+        for i, c in enumerate(string.lowercase + string.uppercase):
+            res = self.lru.get(c)
+            self.check_result(res, short(c), 0, i+1)
+
+    def test_get_exception(self):
+        def fail_miss_fn(k):
+            raise RuntimeError("oh noes")
+        self.lru.miss_fn = fail_miss_fn
+
+        got_exc = False
+        try:
+            self.lru.get('abc')
+        except RuntimeError:
+            got_exc = True
+
+        self.assertEqual(got_exc, True)
+
+    def test_all_hits(self):
+        res = self.lru.get('a')
+        self.check_result(res, short('a'), 0, 1)
+
+        self.lru.miss_fn = long
+        for i in xrange(100):
+            res = self.lru.get('a')
+            self.check_result(res, short('a'), i+1, 1)
+
+    def test_weakrefs(self):
+        res_a = self.lru.get('a')
+        self.check_result(res_a, short('a'))
+        # note that res_a keeps a reference to this value
+
+        res_b = self.lru.get('b')
+        self.check_result(res_b, short('b'))
+        del res_b # discard reference to b
+
+        # blow out the cache and the queue
+        self.lru.miss_fn = long
+        for c in (string.lowercase[2:] * 5):
+            self.lru.get(c)
+
+        # and fetch a again, expecting the cached value
+        res = self.lru.get('a')
+        self.check_result(res, res_a, exp_refhits=1)
+
+        # but 'b' should give us a new value
+        res = self.lru.get('b')
+        self.check_result(res, long('b'), exp_refhits=1)
+
+    def test_fuzz(self):
+        chars = list(string.lowercase * 40)
+        random.shuffle(chars)
+        for i, c in enumerate(chars):
+            res = self.lru.get(c)
+            self.check_result(res, short(c))
+
+    def test_set_max_size(self):
+        # load up the cache with three items
+        for c in 'abc':
+            res = self.lru.get(c)
+            self.check_result(res, short(c))
+        del(res)
+
+        # reset the size to 1
+        self.lru.set_max_size(1)
+        gc.collect()
+
+        # and then expect that 'b' is no longer in the cache
+        self.lru.miss_fn = long
+        res = self.lru.get('b')
+        self.check_result(res, long('b'))
+
+    def test_miss_fn_kwargs(self):
+        def keep_kwargs_miss_fn(k, **kwargs):
+            return set(kwargs.keys())
+        self.lru.miss_fn = keep_kwargs_miss_fn
+
+        val = self.lru.get('a', a=1, b=2)
+        self.check_result(val, set(['a', 'b']), 0, 1)
+
+    def test_miss_fn_returns_none(self):
+        calls = []
+        def none_miss_fn(k):
+            calls.append(k)
+            return None
+        self.lru.miss_fn = none_miss_fn
+
+        for i in range(2):
+            self.assertEqual(self.lru.get('a'), None)
+
+        # check that the miss_fn was called twice
+        self.assertEqual(calls, ['a', 'a'])
+
+    def test_put(self):
+        self.assertEqual(self.lru.get('p'), short('p'))
+        self.lru.put('p', set(['P2P2']))
+        self.assertEqual(self.lru.get('p'), set(['P2P2']))
+
+
+class AsyncLRUCacheTest(unittest.TestCase):
 
     def setUp(self):
         lru.inv_failed = False