Switch to side-by-side view

--- a/Allura/allura/tests/model/test_repo.py
+++ b/Allura/allura/tests/model/test_repo.py
@@ -1,5 +1,11 @@
+from datetime import datetime
+from collections import defaultdict, OrderedDict
+import unittest
+import mock
 from nose.tools import assert_equal
 from pylons import c
+from bson import ObjectId
+from ming.orm import session
 
 from alluratest.controller import setup_basic_test, setup_global_objects
 from allura import model as M
@@ -67,3 +73,483 @@
         self.assertEqual(run.commit_ids, commit_ids)
         self.assertEqual(len(run.commit_ids), len(run.commit_times))
         self.assertEqual(run.parent_commit_ids, [])
+
+
+class TestLastCommit(unittest.TestCase):
+    def setUp(self):
+        setup_basic_test()
+        setup_global_objects()
+        self.repo = mock.Mock('repo', _commits=OrderedDict(), _last_commit=None)
+        self.repo.shorthand_for_commit = lambda _id: _id[:6]
+        self.repo.commits = self._commits
+        lcids = M.repository.RepositoryImplementation.last_commit_ids.__func__
+        self.repo.last_commit_ids = lambda *a, **k: lcids(self.repo, *a, **k)
+
+    def _build_tree(self, commit, path, tree_paths):
+        tree_nodes = []
+        blob_nodes = []
+        sub_paths = defaultdict(list)
+        def n(p):
+            m = mock.Mock()
+            m.name = p
+            return m
+        for p in tree_paths:
+            if '/' in p:
+                node, sub = p.split('/',1)
+                if node not in sub_paths:
+                    tree_nodes.append(n(node))
+                sub_paths[node].append(sub)
+            else:
+                blob_nodes.append(n(p))
+        tree = mock.Mock(
+                commit=commit,
+                path=mock.Mock(return_value=path),
+                tree_ids=tree_nodes,
+                blob_ids=blob_nodes,
+                other_ids=[],
+                repo=self.repo,
+            )
+        tree.get_obj_by_path = lambda p: self._build_tree(commit, p, sub_paths[p])
+        tree.__getitem__ = lambda s, p: self._build_tree(commit, p, sub_paths[p])
+        return tree
+
+    def _add_commit(self, msg, tree_paths, diff_paths=None, parents=[]):
+        suser = dict(
+                name='test',
+                email='test@example.com',
+                date=datetime(2013, 1, 1 + len(self.repo._commits)),
+            )
+        commit = M.repo.Commit(
+                _id=str(ObjectId()),
+                message=msg,
+                parent_ids=[parent._id for parent in parents],
+                commited=suser,
+                authored=suser,
+                repo=self.repo,
+            )
+        commit.tree = self._build_tree(commit, '/', tree_paths)
+        diffinfo = M.repo.DiffInfoDoc(dict(
+                _id=commit._id,
+                differences=[{'name': p} for p in diff_paths or tree_paths],
+            ))
+        diffinfo.m.save()
+        self.repo._commits[commit._id] = commit
+        return commit
+
+    def _commits(self, path, commit_id, limit):
+        return [c._id for c in reversed(self.repo._commits.values()) if path in c.changed_paths][:limit]
+
+    def test_single_commit(self):
+        commit1 = self._add_commit('Commit 1', [
+                'file1',
+                'dir1/file2',
+            ])
+        lcd = M.repo.LastCommit.get(commit1.tree)
+        self.assertEqual(self.repo._commits[lcd.commit_id].message, commit1.message)
+        self.assertEqual(lcd.path, '')
+        self.assertEqual(len(lcd.entries), 2)
+        self.assertEqual(lcd.by_name['file1'], commit1._id)
+        self.assertEqual(lcd.by_name['dir1'], commit1._id)
+
+    def test_multiple_commits_no_overlap(self):
+        commit1 = self._add_commit('Commit 1', ['file1'])
+        commit2 = self._add_commit('Commit 2', ['file1', 'dir1/file1'], ['dir1/file1'], [commit1])
+        commit3 = self._add_commit('Commit 3', ['file1', 'dir1/file1', 'file2'], ['file2'], [commit2])
+        lcd = M.repo.LastCommit.get(commit3.tree)
+        self.assertEqual(self.repo._commits[lcd.commit_id].message, commit3.message)
+        self.assertEqual(lcd.commit_id, commit3._id)
+        self.assertEqual(lcd.path, '')
+        self.assertEqual(len(lcd.entries), 3)
+        self.assertEqual(lcd.by_name['file1'], commit1._id)
+        self.assertEqual(lcd.by_name['dir1'], commit2._id)
+        self.assertEqual(lcd.by_name['file2'], commit3._id)
+
+    def test_multiple_commits_with_overlap(self):
+        commit1 = self._add_commit('Commit 1', ['file1'])
+        commit2 = self._add_commit('Commit 2', ['file1', 'dir1/file1'], ['dir1/file1'], [commit1])
+        commit3 = self._add_commit('Commit 3', ['file1', 'dir1/file1', 'file2'], ['file1', 'file2'], [commit2])
+        lcd = M.repo.LastCommit.get(commit3.tree)
+        self.assertEqual(self.repo._commits[lcd.commit_id].message, commit3.message)
+        self.assertEqual(lcd.path, '')
+        self.assertEqual(len(lcd.entries), 3)
+        self.assertEqual(lcd.by_name['file1'], commit3._id)
+        self.assertEqual(lcd.by_name['dir1'], commit2._id)
+        self.assertEqual(lcd.by_name['file2'], commit3._id)
+
+    def test_multiple_commits_subdir_change(self):
+        commit1 = self._add_commit('Commit 1', ['file1', 'dir1/file1'])
+        commit2 = self._add_commit('Commit 2', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file2'], [commit1])
+        commit3 = self._add_commit('Commit 3', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file1'], [commit2])
+        lcd = M.repo.LastCommit.get(commit3.tree)
+        self.assertEqual(self.repo._commits[lcd.commit_id].message, commit3.message)
+        self.assertEqual(lcd.path, '')
+        self.assertEqual(len(lcd.entries), 2)
+        self.assertEqual(lcd.by_name['file1'], commit1._id)
+        self.assertEqual(lcd.by_name['dir1'], commit3._id)
+
+    def test_subdir_lcd(self):
+        commit1 = self._add_commit('Commit 1', ['file1', 'dir1/file1'])
+        commit2 = self._add_commit('Commit 2', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file2'], [commit1])
+        commit3 = self._add_commit('Commit 3', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file1'], [commit2])
+        tree = self._build_tree(commit3, '/dir1', ['file1', 'file2'])
+        lcd = M.repo.LastCommit.get(tree)
+        self.assertEqual(self.repo._commits[lcd.commit_id].message, commit3.message)
+        self.assertEqual(lcd.path, 'dir1')
+        self.assertEqual(len(lcd.entries), 2)
+        self.assertEqual(lcd.by_name['file1'], commit3._id)
+        self.assertEqual(lcd.by_name['file2'], commit2._id)
+
+    def test_subdir_lcd_prev_commit(self):
+        commit1 = self._add_commit('Commit 1', ['file1', 'dir1/file1'])
+        commit2 = self._add_commit('Commit 2', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file2'], [commit1])
+        commit3 = self._add_commit('Commit 3', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file1'], [commit2])
+        commit4 = self._add_commit('Commit 4', ['file1', 'dir1/file1', 'dir1/file2', 'file2'], ['file2'], [commit3])
+        tree = self._build_tree(commit4, '/dir1', ['file1', 'file2'])
+        lcd = M.repo.LastCommit.get(tree)
+        self.assertEqual(self.repo._commits[lcd.commit_id].message, commit3.message)
+        self.assertEqual(lcd.path, 'dir1')
+        self.assertEqual(len(lcd.entries), 2)
+        self.assertEqual(lcd.by_name['file1'], commit3._id)
+        self.assertEqual(lcd.by_name['file2'], commit2._id)
+
+    def test_subdir_lcd_always_empty(self):
+        commit1 = self._add_commit('Commit 1', ['file1', 'dir1'])
+        commit2 = self._add_commit('Commit 2', ['file1', 'file2'], ['file2'], [commit1])
+        tree = self._build_tree(commit2, '/dir1', [])
+        lcd = M.repo.LastCommit.get(tree)
+        self.assertEqual(self.repo._commits[lcd.commit_id].message, commit1.message)
+        self.assertEqual(lcd.path, 'dir1')
+        self.assertEqual(lcd.entries, [])
+
+    def test_subdir_lcd_emptied(self):
+        commit1 = self._add_commit('Commit 1', ['file1', 'dir1/file1'])
+        commit2 = self._add_commit('Commit 2', ['file1'], ['dir1/file1'], [commit1])
+        tree = self._build_tree(commit2, '/dir1', [])
+        lcd = M.repo.LastCommit.get(tree)
+        self.assertEqual(self.repo._commits[lcd.commit_id].message, commit2.message)
+        self.assertEqual(lcd.path, 'dir1')
+        self.assertEqual(lcd.entries, [])
+
+    def test_existing_lcd_unchained(self):
+        commit1 = self._add_commit('Commit 1', ['file1', 'dir1/file1'])
+        commit2 = self._add_commit('Commit 2', ['file1', 'dir1/file1', 'dir1/file2'], ['dir1/file2'], [commit1])
+        commit3 = self._add_commit('Commit 3', ['file1', 'dir1/file1', 'dir1/file2'], ['file1'], [commit2])
+        prev_lcd = M.repo.LastCommit(
+                path='dir1',
+                commit_id=commit2._id,
+                entries=[
+                    dict(
+                        name='file1',
+                        commit_id=commit1._id),
+                    dict(
+                        name='file2',
+                        commit_id=commit2._id),
+                ],
+            )
+        session(prev_lcd).flush()
+        tree = self._build_tree(commit3, '/dir1', ['file1', 'file2'])
+        lcd = M.repo.LastCommit.get(tree)
+        self.assertEqual(lcd._id, prev_lcd._id)
+        self.assertEqual(self.repo._commits[lcd.commit_id].message, commit2.message)
+        self.assertEqual(lcd.path, 'dir1')
+        self.assertEqual(lcd.entries, prev_lcd.entries)
+
+    def test_existing_lcd_partial(self):
+        commit1 = self._add_commit('Commit 1', ['file1'])
+        commit2 = self._add_commit('Commit 2', ['file1', 'file2'], ['file2'], [commit1])
+        commit3 = self._add_commit('Commit 3', ['file1', 'file2', 'file3'], ['file3'], [commit2])
+        commit4 = self._add_commit('Commit 4', ['file1', 'file2', 'file3', 'file4'], ['file2', 'file4'], [commit3])
+        prev_lcd = M.repo.LastCommit(
+                path='',
+                commit_id=commit3._id,
+                entries=[
+                    dict(
+                        name='file1',
+                        commit_id=commit1._id),
+                    dict(
+                        name='file2',
+                        commit_id=commit2._id),
+                    dict(
+                        name='file3',
+                        commit_id=commit3._id),
+                ],
+            )
+        session(prev_lcd).flush()
+        lcd = M.repo.LastCommit.get(commit4.tree)
+        self.assertEqual(self.repo._commits[lcd.commit_id].message, commit4.message)
+        self.assertEqual(lcd.path, '')
+        self.assertEqual(len(lcd.entries), 4)
+        self.assertEqual(lcd.by_name['file1'], commit1._id)
+        self.assertEqual(lcd.by_name['file2'], commit4._id)
+        self.assertEqual(lcd.by_name['file3'], commit3._id)
+        self.assertEqual(lcd.by_name['file4'], commit4._id)
+
+
+class TestModelCache(unittest.TestCase):
+    def setUp(self):
+        self.cache = M.repo.ModelCache()
+
+    def test_normalize_query(self):
+        self.assertEqual(self.cache._normalize_query({'foo': 1, 'bar': 2}), (('bar', 2), ('foo', 1)))
+
+    def test_model_query(self):
+        q = mock.Mock(spec_set=['query'], query='foo')
+        m = mock.Mock(spec_set=['m'], m='bar')
+        n = mock.Mock(spec_set=['foo'], foo='qux')
+        self.assertEquals(self.cache._model_query(q), 'foo')
+        self.assertEquals(self.cache._model_query(m), 'bar')
+        self.assertRaises(AttributeError, self.cache._model_query, [n])
+
+    @mock.patch.object(M.repo.Tree.query, 'get')
+    @mock.patch.object(M.repo.LastCommit.query, 'get')
+    def test_get(self, lc_get, tr_get):
+        tree = tr_get.return_value = mock.Mock(spec=['_id', 'val'], _id='foo', val='bar')
+        lcd = lc_get.return_value = mock.Mock(spec=['_id', 'val'], _id='foo', val='qux')
+
+        val = self.cache.get(M.repo.Tree, {'_id': 'foo'})
+        tr_get.assert_called_with(_id='foo')
+        self.assertEqual(val, tree)
+
+        val = self.cache.get(M.repo.LastCommit, {'_id': 'foo'})
+        lc_get.assert_called_with(_id='foo')
+        self.assertEqual(val, lcd)
+
+    @mock.patch.object(M.repo.Tree.query, 'get')
+    def test_get_no_query(self, tr_get):
+        tree1 = tr_get.return_value = mock.Mock(spec=['_id', 'val'], _id='foo', val='bar')
+        val = self.cache.get(M.repo.Tree, {'_id': 'foo'})
+        tr_get.assert_called_once_with(_id='foo')
+        self.assertEqual(val, tree1)
+
+        tree2 = tr_get.return_value = mock.Mock(_id='foo', val='qux')
+        val = self.cache.get(M.repo.Tree, {'_id': 'foo'})
+        tr_get.assert_called_once_with(_id='foo')
+        self.assertEqual(val, tree1)
+
+    @mock.patch.object(M.repo.TreesDoc.m, 'get')
+    def test_get_doc(self, tr_get):
+        trees = tr_get.return_value = mock.Mock(spec=['_id', 'val'], _id='foo', val='bar')
+        val = self.cache.get(M.repo.TreesDoc, {'_id': 'foo'})
+        tr_get.assert_called_once_with(_id='foo')
+        self.assertEqual(val, trees)
+
+    def test_set(self):
+        tree = mock.Mock(spec=['_id', 'test_set'], _id='foo', val='test_set')
+        self.cache.set(M.repo.Tree, {'val': 'test_set'}, tree)
+        self.assertEqual(self.cache._query_cache, {M.repo.Tree: {(('val', 'test_set'),): 'foo'}})
+        self.assertEqual(self.cache._instance_cache, {M.repo.Tree: {'foo': tree}})
+
+    @mock.patch('bson.ObjectId')
+    def test_set_none_id(self, obj_id):
+        obj_id.return_value = 'OBJID'
+        tree = mock.Mock(spec=['_id', 'test_set'], _id=None, val='test_set')
+        self.cache.set(M.repo.Tree, {'val1': 'test_set1'}, tree)
+        self.cache.set(M.repo.Tree, {'val2': 'test_set2'}, tree)
+        self.assertEqual(dict(self.cache._query_cache[M.repo.Tree]), {
+                (('val1', 'test_set1'),): 'OBJID',
+                (('val2', 'test_set2'),): 'OBJID',
+            })
+        self.assertEqual(self.cache._instance_cache, {M.repo.Tree: {'OBJID': tree}})
+        tree._id = '_id'
+        self.assertEqual(self.cache.get(M.repo.Tree, {'val1': 'test_set1'}), tree)
+        self.assertEqual(self.cache.get(M.repo.Tree, {'val2': 'test_set2'}), tree)
+        self.cache.set(M.repo.Tree, {'val1': 'test_set2'}, tree)
+        self.assertEqual(self.cache.get(M.repo.Tree, {'val1': 'test_set1'}), tree)
+        self.assertEqual(self.cache.get(M.repo.Tree, {'val2': 'test_set2'}), tree)
+
+    @mock.patch('bson.ObjectId')
+    def test_set_none_val(self, obj_id):
+        obj_id.return_value = 'OBJID'
+        self.cache.set(M.repo.Tree, {'val1': 'test_set1'}, None)
+        self.cache.set(M.repo.Tree, {'val2': 'test_set2'}, None)
+        self.assertEqual(dict(self.cache._query_cache[M.repo.Tree]), {
+                (('val1', 'test_set1'),): None,
+                (('val2', 'test_set2'),): None,
+            })
+        self.assertEqual(dict(self.cache._instance_cache[M.repo.Tree]), {})
+        tree1 = mock.Mock(spec=['_id', 'val'], _id='tree1', val='test_set')
+        tree2 = mock.Mock(spec=['_model_cache_id', '_id', 'val'], _model_cache_id='tree2', _id='tree1', val='test_set2')
+        self.cache.set(M.repo.Tree, {'val1': 'test_set1'}, tree1)
+        self.cache.set(M.repo.Tree, {'val2': 'test_set2'}, tree2)
+        self.assertEqual(dict(self.cache._query_cache[M.repo.Tree]), {
+                (('val1', 'test_set1'),): 'tree1',
+                (('val2', 'test_set2'),): 'tree2',
+            })
+        self.assertEqual(dict(self.cache._instance_cache[M.repo.Tree]), {
+                'tree1': tree1,
+                'tree2': tree2,
+            })
+
+    def test_instance_ids(self):
+        tree1 = mock.Mock(spec=['_id', 'val'], _id='id1', val='tree1')
+        tree2 = mock.Mock(spec=['_id', 'val'], _id='id2', val='tree2')
+        self.cache.set(M.repo.Tree, {'val': 'tree1'}, tree1)
+        self.cache.set(M.repo.Tree, {'val': 'tree2'}, tree2)
+        self.assertEqual(set(self.cache.instance_ids(M.repo.Tree)), set(['id1', 'id2']))
+        self.assertEqual(self.cache.instance_ids(M.repo.LastCommit), [])
+
+    @mock.patch.object(M.repo.Tree.query, 'find')
+    def test_batch_load(self, tr_find):
+        # cls, query, attrs
+        m1 = mock.Mock(spec=['_id', 'foo', 'qux'], _id='id1', foo=1, qux=3)
+        m2 = mock.Mock(spec=['_id', 'foo', 'qux'], _id='id2', foo=2, qux=5)
+        tr_find.return_value = [m1, m2]
+
+        self.cache.batch_load(M.repo.Tree, {'foo': {'$in': 'bar'}})
+        tr_find.assert_called_with({'foo': {'$in': 'bar'}})
+        self.assertEqual(self.cache._query_cache[M.repo.Tree], {
+                (('foo', 1),): 'id1',
+                (('foo', 2),): 'id2',
+            })
+        self.assertEqual(self.cache._instance_cache[M.repo.Tree], {
+                'id1': m1,
+                'id2': m2,
+            })
+
+    @mock.patch.object(M.repo.Tree.query, 'find')
+    def test_batch_load_attrs(self, tr_find):
+        # cls, query, attrs
+        m1 = mock.Mock(spec=['_id', 'foo', 'qux'], _id='id1', foo=1, qux=3)
+        m2 = mock.Mock(spec=['_id', 'foo', 'qux'], _id='id2', foo=2, qux=5)
+        tr_find.return_value = [m1, m2]
+
+        self.cache.batch_load(M.repo.Tree, {'foo': {'$in': 'bar'}}, ['qux'])
+        tr_find.assert_called_with({'foo': {'$in': 'bar'}})
+        self.assertEqual(self.cache._query_cache[M.repo.Tree], {
+                (('qux', 3),): 'id1',
+                (('qux', 5),): 'id2',
+            })
+        self.assertEqual(self.cache._instance_cache[M.repo.Tree], {
+                'id1': m1,
+                'id2': m2,
+            })
+
+    def test_pruning(self):
+        cache = M.repo.ModelCache(max_queries=3, max_instances=2)
+        # ensure cache expires as LRU
+        tree1 = mock.Mock(spec=['_id', '_val'], _id='foo', val='bar')
+        tree2 = mock.Mock(spec=['_id', '_val'], _id='qux', val='fuz')
+        tree3 = mock.Mock(spec=['_id', '_val'], _id='f00', val='b4r')
+        tree4 = mock.Mock(spec=['_id', '_val'], _id='foo', val='zaz')
+        cache.set(M.repo.Tree, {'_id': 'foo'}, tree1)
+        cache.set(M.repo.Tree, {'_id': 'qux'}, tree2)
+        cache.set(M.repo.Tree, {'_id': 'f00'}, tree3)
+        cache.set(M.repo.Tree, {'_id': 'foo'}, tree4)
+        cache.get(M.repo.Tree, {'_id': 'f00'})
+        cache.set(M.repo.Tree, {'val': 'b4r'}, tree3)
+        self.assertEqual(cache._query_cache, {
+                M.repo.Tree: {
+                    (('_id', 'foo'),): 'foo',
+                    (('_id', 'f00'),): 'f00',
+                    (('val', 'b4r'),): 'f00',
+                },
+            })
+        self.assertEqual(cache._instance_cache, {
+                M.repo.Tree: {
+                    'f00': tree3,
+                    'foo': tree4,
+                },
+            })
+
+    def test_pruning_query_vs_instance(self):
+        cache = M.repo.ModelCache(max_queries=3, max_instances=2)
+        # ensure cache expires as LRU
+        tree1 = mock.Mock(spec=['_id', '_val'], _id='keep', val='bar')
+        tree2 = mock.Mock(spec=['_id', '_val'], _id='tree2', val='fuz')
+        tree3 = mock.Mock(spec=['_id', '_val'], _id='tree3', val='b4r')
+        tree4 = mock.Mock(spec=['_id', '_val'], _id='tree4', val='zaz')
+        cache.set(M.repo.Tree, {'keep_query_1': 'bar'}, tree1)
+        cache.set(M.repo.Tree, {'drop_query_1': 'bar'}, tree2)
+        cache.set(M.repo.Tree, {'keep_query_2': 'bar'}, tree1)  # should refresh tree1 in _instance_cache
+        cache.set(M.repo.Tree, {'drop_query_2': 'bar'}, tree3)  # should drop tree2, not tree1, from _instance_cache
+        self.assertEqual(cache._query_cache[M.repo.Tree], {
+                (('drop_query_1', 'bar'),): 'tree2',
+                (('keep_query_2', 'bar'),): 'keep',
+                (('drop_query_2', 'bar'),): 'tree3',
+            })
+        self.assertEqual(cache._instance_cache[M.repo.Tree], {
+                'keep': tree1,
+                'tree3': tree3,
+            })
+
+    @mock.patch('bson.ObjectId')
+    def test_pruning_no_id(self, obj_id):
+        obj_id.side_effect = ['id1', 'id2', 'id3']
+        cache = M.repo.ModelCache(max_queries=3, max_instances=2)
+        # ensure cache considers same instance equal to itself, even if no _id
+        tree1 = mock.Mock(spec=['val'], val='bar')
+        cache.set(M.repo.Tree, {'query_1': 'bar'}, tree1)
+        cache.set(M.repo.Tree, {'query_2': 'bar'}, tree1)
+        cache.set(M.repo.Tree, {'query_3': 'bar'}, tree1)
+        self.assertEqual(cache._instance_cache[M.repo.Tree], {
+                'id1': tree1,
+            })
+        self.assertEqual(cache._query_cache[M.repo.Tree], {
+                (('query_1', 'bar'),): 'id1',
+                (('query_2', 'bar'),): 'id1',
+                (('query_3', 'bar'),): 'id1',
+            })
+
+    @mock.patch('bson.ObjectId')
+    def test_pruning_none(self, obj_id):
+        obj_id.side_effect = ['id1', 'id2', 'id3']
+        cache = M.repo.ModelCache(max_queries=3, max_instances=2)
+        # ensure cache doesn't store None instances
+        cache.set(M.repo.Tree, {'query_1': 'bar'}, None)
+        cache.set(M.repo.Tree, {'query_2': 'bar'}, None)
+        cache.set(M.repo.Tree, {'query_3': 'bar'}, None)
+        self.assertEqual(cache._instance_cache[M.repo.Tree], {})
+        self.assertEqual(cache._query_cache[M.repo.Tree], {
+                (('query_1', 'bar'),): None,
+                (('query_2', 'bar'),): None,
+                (('query_3', 'bar'),): None,
+            })
+
+    @mock.patch('allura.model.repo.session')
+    @mock.patch.object(M.repo.Tree.query, 'get')
+    def test_pruning_query_flush(self, tr_get, session):
+        cache = M.repo.ModelCache(max_queries=3, max_instances=2)
+        # ensure cache doesn't store None instances
+        tree1 = mock.Mock(name='tree1', spec=['_id', '_val'], _id='tree1', val='bar')
+        tree2 = mock.Mock(name='tree2', spec=['_id', '_val'], _id='tree2', val='fuz')
+        tr_get.return_value = tree2
+        cache.set(M.repo.Tree, {'_id': 'tree1'}, tree1)
+        cache.set(M.repo.Tree, {'_id': 'tree2'}, tree2)
+        cache.get(M.repo.Tree, {'query_1': 'tree2'})
+        cache.get(M.repo.Tree, {'query_2': 'tree2'})
+        cache.get(M.repo.Tree, {'query_3': 'tree2'})
+        self.assertEqual(cache._query_cache[M.repo.Tree], {
+                (('query_1', 'tree2'),): 'tree2',
+                (('query_2', 'tree2'),): 'tree2',
+                (('query_3', 'tree2'),): 'tree2',
+            })
+        self.assertEqual(cache._instance_cache[M.repo.Tree], {
+                'tree1': tree1,
+                'tree2': tree2,
+            })
+        self.assertEqual(session.call_args_list, [mock.call(tree1), mock.call(tree2)])
+        self.assertEqual(session.return_value.flush.call_args_list, [mock.call(tree1), mock.call(tree2)])
+        assert not session.return_value.expunge.called
+
+    @mock.patch('allura.model.repo.session')
+    def test_pruning_instance_flush(self, session):
+        cache = M.repo.ModelCache(max_queries=3, max_instances=2)
+        # ensure cache doesn't store None instances
+        tree1 = mock.Mock(spec=['_id', '_val'], _id='tree1', val='bar')
+        tree2 = mock.Mock(spec=['_id', '_val'], _id='tree2', val='fuz')
+        tree3 = mock.Mock(spec=['_id', '_val'], _id='tree3', val='qux')
+        cache.set(M.repo.Tree, {'_id': 'tree1'}, tree1)
+        cache.set(M.repo.Tree, {'_id': 'tree2'}, tree2)
+        cache.set(M.repo.Tree, {'_id': 'tree3'}, tree3)
+        self.assertEqual(cache._query_cache[M.repo.Tree], {
+                (('_id', 'tree1'),): 'tree1',
+                (('_id', 'tree2'),): 'tree2',
+                (('_id', 'tree3'),): 'tree3',
+            })
+        self.assertEqual(cache._instance_cache[M.repo.Tree], {
+                'tree2': tree2,
+                'tree3': tree3,
+            })
+        session.assert_called_once_with(tree1)
+        session.return_value.flush.assert_called_once_with(tree1)
+        session.return_value.expunge.assert_called_once_with(tree1)