Repository: evennia/evennia

Issue:
[BUG] AttributeHandler continues caching attributes even when explicitly disabled
#### Describe the bug
In trying to get to the bottom of some memory leaks, I noticed that when `TYPECLASS_AGGRESSIVE_CACHE` is set to `False`, the AttributeHandler will ignore the cache for reads but will still continue to write to it.

#### To Reproduce
````
from evennia.utils.test_resources import EvenniaTest
from django.conf import settings
from django.test import override_settings
from mock import patch


class AttributeCachingTest(EvenniaTest):

    @override_settings(TYPECLASS_AGGRESSIVE_CACHE=False)
    @patch('evennia.typeclasses.attributes._TYPECLASS_AGGRESSIVE_CACHE', False)
    def test_caching(self):
        "Test to make sure when caching is disabled, it stays disabled."
        # Make sure the setting override actually stuck
        self.assertFalse(settings.TYPECLASS_AGGRESSIVE_CACHE)
        from evennia.typeclasses.attributes import _TYPECLASS_AGGRESSIVE_CACHE
        self.assertFalse(_TYPECLASS_AGGRESSIVE_CACHE)
        
        # Add an example attribute
        self.obj1.attributes.add('phrase', 'xyzzy')
        
        # Cache should not have been touched.
        self.assertFalse(self.obj1.attributes._cache, f"Cache contents (should be empty): {self.obj1.attributes._cache}")
````

#### Expected behavior
Not sure if this was intentional, but it is self-defeating. It is definitely a major factor in the memory leak since some obscenely long user-defined lists managed to get stored in Attributes and are persisted in memory on every write despite my frustrated efforts to purge them.

#### Environment, Evennia version, OS etc
````
Evennia 0.9.0 (rev ff7b739) (rev ff7b739)
    OS: posix
    Python: 3.7.5
    Twisted: 19.7.0
    Django: 2.2.6
````

#### Additional context
The cache invalidation mechanism seems to be based on a time interval that does not keep up with intensive I/O and leaves the server open to resource exhaustion in the interim. Curiously there is a `LimitedSizeOrderedDict` in the utils module purpose-built for caching but the AttributeHandler is using a plain `dict` for its cache instead, which lends itself to boundless growth.

Django's own caching framework invalidates keys based on both time and number to get around these sorts of issues (can even use the filesystem as the backend for caching, not just in-memory only) but it is not used by Evennia. Either option might be worth switching to.


Discussion:

Resolving patch:
diff --git a/evennia/typeclasses/attributes.py b/evennia/typeclasses/attributes.py
--- a/evennia/typeclasses/attributes.py
+++ b/evennia/typeclasses/attributes.py
@@ -237,6 +237,7 @@ def __init__(self, obj):
 
     def _fullcache(self):
         """Cache all attributes of this object"""
+        if not _TYPECLASS_AGGRESSIVE_CACHE: return
         query = {
             "%s__id" % self._model: self._objid,
             "attribute__db_model__iexact": self._model,
@@ -298,7 +299,7 @@ def _getcache(self, key=None, category=None):
                 attr = None
                 cachefound = False
                 del self._cache[cachekey]
-            if cachefound:
+            if cachefound and _TYPECLASS_AGGRESSIVE_CACHE:
                 if attr:
                     return [attr]  # return cached entity
                 else:
@@ -316,13 +317,15 @@ def _getcache(self, key=None, category=None):
                 conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
                 if conn:
                     attr = conn[0].attribute
-                    self._cache[cachekey] = attr
+                    if _TYPECLASS_AGGRESSIVE_CACHE: 
+                        self._cache[cachekey] = attr
                     return [attr] if attr.pk else []
                 else:
                     # There is no such attribute. We will explicitly save that
                     # in our cache to avoid firing another query if we try to
                     # retrieve that (non-existent) attribute again.
-                    self._cache[cachekey] = None
+                    if _TYPECLASS_AGGRESSIVE_CACHE:
+                        self._cache[cachekey] = None
                     return []
         else:
             # only category given (even if it's None) - we can't
@@ -345,12 +348,13 @@ def _getcache(self, key=None, category=None):
                         **query
                     )
                 ]
-                for attr in attrs:
-                    if attr.pk:
-                        cachekey = "%s-%s" % (attr.db_key, category)
-                        self._cache[cachekey] = attr
-                # mark category cache as up-to-date
-                self._catcache[catkey] = True
+                if _TYPECLASS_AGGRESSIVE_CACHE:
+                    for attr in attrs:
+                        if attr.pk:
+                            cachekey = "%s-%s" % (attr.db_key, category)
+                            self._cache[cachekey] = attr
+                    # mark category cache as up-to-date
+                    self._catcache[catkey] = True
                 return attrs
 
     def _setcache(self, key, category, attr_obj):
@@ -363,6 +367,7 @@ def _setcache(self, key, category, attr_obj):
             attr_obj (Attribute): The newly saved attribute
 
         """
+        if not _TYPECLASS_AGGRESSIVE_CACHE: return
         if not key:  # don't allow an empty key in cache
             return
         cachekey = "%s-%s" % (key, category)
diff --git a/evennia/typeclasses/tags.py b/evennia/typeclasses/tags.py
--- a/evennia/typeclasses/tags.py
+++ b/evennia/typeclasses/tags.py
@@ -121,6 +121,7 @@ def __init__(self, obj):
 
     def _fullcache(self):
         "Cache all tags of this object"
+        if not _TYPECLASS_AGGRESSIVE_CACHE: return
         query = {
             "%s__id" % self._model: self._objid,
             "tag__db_model": self._model,
@@ -188,7 +189,8 @@ def _getcache(self, key=None, category=None):
                 conn = getattr(self.obj, self._m2m_fieldname).through.objects.filter(**query)
                 if conn:
                     tag = conn[0].tag
-                    self._cache[cachekey] = tag
+                    if _TYPECLASS_AGGRESSIVE_CACHE:
+                        self._cache[cachekey] = tag
                     return [tag]
         else:
             # only category given (even if it's None) - we can't
@@ -211,11 +213,12 @@ def _getcache(self, key=None, category=None):
                         **query
                     )
                 ]
-                for tag in tags:
-                    cachekey = "%s-%s" % (tag.db_key, category)
-                    self._cache[cachekey] = tag
-                # mark category cache as up-to-date
-                self._catcache[catkey] = True
+                if _TYPECLASS_AGGRESSIVE_CACHE:
+                    for tag in tags:
+                        cachekey = "%s-%s" % (tag.db_key, category)
+                        self._cache[cachekey] = tag
+                    # mark category cache as up-to-date
+                    self._catcache[catkey] = True
                 return tags
         return []
 
@@ -229,6 +232,7 @@ def _setcache(self, key, category, tag_obj):
             tag_obj (tag): The newly saved tag
 
         """
+        if not _TYPECLASS_AGGRESSIVE_CACHE: return
         if not key:  # don't allow an empty key in cache
             return
         key, category = key.strip().lower(), category.strip().lower() if category else category


Test patch:
diff --git a/evennia/typeclasses/tests.py b/evennia/typeclasses/tests.py
--- a/evennia/typeclasses/tests.py
+++ b/evennia/typeclasses/tests.py
@@ -2,8 +2,9 @@
 Unit tests for typeclass base system
 
 """
-
+from django.test import override_settings
 from evennia.utils.test_resources import EvenniaTest
+from mock import patch
 
 # ------------------------------------------------------------
 # Manager tests
@@ -18,6 +19,19 @@ def test_attrhandler(self):
         self.assertEqual(self.obj1.attributes.get(key), value)
         self.obj1.db.testattr = value
         self.assertEqual(self.obj1.db.testattr, value)
+        
+    @override_settings(TYPECLASS_AGGRESSIVE_CACHE=False)
+    @patch('evennia.typeclasses.attributes._TYPECLASS_AGGRESSIVE_CACHE', False)
+    def test_attrhandler_nocache(self):
+        key = "testattr"
+        value = "test attr value "
+        self.obj1.attributes.add(key, value)
+        self.assertFalse(self.obj1.attributes._cache)
+        
+        self.assertEqual(self.obj1.attributes.get(key), value)
+        self.obj1.db.testattr = value
+        self.assertEqual(self.obj1.db.testattr, value)
+        self.assertFalse(self.obj1.attributes._cache)
 
     def test_weird_text_save(self):
         "test 'weird' text type (different in py2 vs py3)"