Opened 4 weeks ago

Last modified 3 weeks ago

#36662 assigned Cleanup/optimization

django.contrib.messages Storage creates circular references with Request objects

Reported by: Raphael Gaschignard Owned by: Augusto Pontes
Component: contrib.messages Version: dev
Severity: Normal Keywords: gc, garbage, weakref
Cc: Raphael Gaschignard Triage Stage: Accepted
Has patch: yes Needs documentation: no
Needs tests: yes Patch needs improvement: yes
Easy pickings: no UI/UX: no

Description (last modified by Raphael Gaschignard)

To be honest I don't know how much people care about this kind of issue, but I am having some (very silly) operational issues downstream of Requests getting garbage collected at awkward times.

Request objects hold onto message storages via Request._messages. The storage classes hold references back to Request. This creates a reference cycle preventing Requests from being collected as fast as they could be (and can create some awkwardness in CPython, with del calls happening on associated objects in seemingly unrelated spots)

The "simple" fix here would be to hold onto request in the storage through a weakref, and add a property to get the request object on the storage class.

An example of the sort of cycle that appears (objgraph output)

Attachments (1)

cycle.2.png (57.1 KB ) - added by Raphael Gaschignard 4 weeks ago.
An example of the sort of cycle that appears (objgraph output)

Download all attachments as: .zip

Change History (12)

by Raphael Gaschignard, 4 weeks ago

Attachment: cycle.2.png added

An example of the sort of cycle that appears (objgraph output)

comment:1 by Raphael Gaschignard, 4 weeks ago

Description: modified (diff)

comment:2 by Manish Tiwari, 3 weeks ago

Does someone working on this issue, or it's good to go for starting out for this??

comment:3 by Augusto Pontes, 3 weeks ago

Kinda complex issue, but thanks to send the image, it clarified a lot, and definitely this circular reference can cause not only garbage collector issues, but in my point of view: performance, i will analyse this module on django core, and see if i can give any suggestions, also, we need to see if writing any changes on this module, can affect not only other modules, but the unit tests

comment:4 by Augusto Pontes, 3 weeks ago

Owner: set to Augusto Pontes
Status: newassigned

comment:5 by Jacob Walls, 3 weeks ago

Keywords: gc garbage weakref added
Triage Stage: UnreviewedAccepted

Thanks, I reproduced by riffing on the test from #34865:

  • tests/messages_tests/base.py

    diff --git a/tests/messages_tests/base.py b/tests/messages_tests/base.py
    index ce4b2acac8..b512788ec0 100644
    a b class BaseTests:  
    330330        self.assertEqual(len(storage), 6)
    331331
    332332    def test_high_level(self):
     333        import gc
     334   
     335        # Schedule the restore of the garbage collection settings.
     336        self.addCleanup(gc.set_debug, 0)
     337        self.addCleanup(gc.enable)
     338
     339        # Disable automatic garbage collection to control when it's triggered,
     340        # then run a full collection cycle to ensure `gc.garbage` is empty.
     341        gc.disable()
     342        gc.collect()
     343
     344        # The garbage list isn't automatically populated to avoid CPU overhead,
     345        # so debugging needs to be enabled to track all unreachable items and
     346        # have them stored in `gc.garbage`.
     347        gc.set_debug(gc.DEBUG_SAVEALL)
     348
    333349        request = self.get_request()
    334350        storage = self.storage_class(request)
    335351        request._messages = storage
    class BaseTests:  
    340356        add_level_messages(storage)
    341357        self.assertEqual(len(storage), 2)
    342358
     359        # Dereference.
     360        request = None
     361        storage = None
     362
     363        # Enforce garbage collection to populate `gc.garbage` for inspection.
     364        gc.collect()
     365        self.assertEqual(gc.garbage, [])
     366
     367
    343368    @override_settings(MESSAGE_LEVEL=29)
    344369    def test_settings_level(self):
    345370        request = self.get_request()
AssertionError: Lists differ: [<HttpRequest>, <QueryDict: {}>, <QueryDic[237 chars], []] != []

First list contains 11 additional elements.
First extra element 0:
<HttpRequest>

+ []
- [<HttpRequest>,
-  <QueryDict: {}>,
-  <QueryDict: {}>,
-  {},
-  {},
-  <MultiValueDict: {}>,
-  <SessionStorage: request=<HttpRequest>>,
-  [Message(level=30, message='A warning'),
-   Message(level=40, message='An error')],
-  Message(level=30, message='A warning'),
-  Message(level=40, message='An error'),
-  []]

The "simple" fix here would be to hold onto request in the storage through a weakref, and add a property to get the request object on the storage class.

I tried that:

  • django/contrib/messages/storage/base.py

    diff --git a/django/contrib/messages/storage/base.py b/django/contrib/messages/storage/base.py
    index 5d89acfe69..9bca9e04c2 100644
    a b  
     1import weakref
    12from django.conf import settings
    23from django.contrib.messages import constants, utils
    34from django.utils.functional import SimpleLazyObject
    class BaseStorage:  
    5556    """
    5657
    5758    def __init__(self, request, *args, **kwargs):
    58         self.request = request
     59        self._request = weakref.proxy(request)
    5960        self._queued_messages = []
    6061        self.used = False
    6162        self.added_new = False
    6263        super().__init__(*args, **kwargs)
    6364
     65    @property
     66    def request(self):
     67        return self._request
     68
    6469    def __len__(self):
    6570        return len(self._loaded_messages) + len(self._queued_messages)

But I just got:

ERROR: test_add_lazy_translation (messages_tests.test_session.SessionTests.test_add_lazy_translation)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/jwalls/django/tests/messages_tests/base.py", line 101, in test_add_lazy_translation
    storage.update(response)
    ~~~~~~~~~~~~~~^^^^^^^^^^
  File "/Users/jwalls/django/django/contrib/messages/storage/base.py", line 145, in update
    return self._store(messages, response)
           ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  File "/Users/jwalls/django/django/contrib/messages/storage/session.py", line 40, in _store
    self.request.session[self.session_key] = self.serialize_messages(messages)
    ^^^^^^^^^^^^^^^^^^^^
ReferenceError: weakly-referenced object no longer exists

----------------------------------------------------------------------
Ran 4 tests in 0.008s
Version 0, edited 3 weeks ago by Jacob Walls (next)

comment:6 by Jacob Walls, 3 weeks ago

Tentatively accepting on the premise that there is a non-awkward solution for this given the way this app uses middleware.

Last edited 3 weeks ago by Jacob Walls (previous) (diff)

in reply to:  6 comment:7 by Augusto Pontes, 3 weeks ago

Replying to Jacob Walls:

Tentatively accepting on the premise that there is a non-awkward solution for this given the way this app uses middleware.

But its still possible to fix it?

comment:8 by Jacob Walls, 3 weeks ago

Sure, sharing any additional findings will be a help.

comment:9 by Augusto Pontes, 3 weeks ago

I created a thread on the django discord server, here is the link: https://discord.gg/4VtuEf2J, the thread has the same name of the ticket here

comment:11 by Augusto Pontes, 3 weeks ago

Has patch: set
Needs tests: set
Patch needs improvement: set
Note: See TracTickets for help on using tickets.
Back to Top