-
-
Notifications
You must be signed in to change notification settings - Fork 31.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fixed #27176 -- Raised an exception for reentrant calls to apps.populate #7682
Fixed #27176 -- Raised an exception for reentrant calls to apps.populate #7682
Conversation
dcb29d6
to
4baecfa
Compare
django/apps/registry.py
Outdated
@@ -44,7 +44,8 @@ def __init__(self, installed_apps=()): | |||
self.apps_ready = self.models_ready = self.ready = False | |||
|
|||
# Lock for thread-safe population. | |||
self._lock = threading.Lock() | |||
self._lock = threading.RLock() | |||
self._populate_running = False |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's a shame that RLock
doesn't expose its _count
attribute (in the Python implementation; I didn't check the C implementation but it certainly has a variable that fulfills the same role).
A comment stating that's the reason why you need a separate _populate_running
to track whether the lock is acquired for the first time or again by the same thread would help.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See below for a more drastic suggestion to improve the behavior of this flag.
django/apps/registry.py
Outdated
else: | ||
app_config = AppConfig.create(entry) | ||
if app_config.label in self.app_configs: | ||
try: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's unclear to me why you need this try / finally
block.
If an exception happens populate()
in an "inner" call — and any inner call will raise an exception just below — _populate_running
shouldn't be reset to False
until the "outer" call terminates.
In fact I don't think we need to reset _populate_running
at all. I think it will be just as easy to make it a simple boolean that starts at False
and moves to True
when populate()
is called for the first time. That will be consistent with (apps_|models_|)ready
. I'd just call it loading
or started
and not bother reset it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I needed to reset the _populate_running flag because I misused set_installed_apps
in the test. Using the context manager with self.settings(INSTALLED_APPS=[...]):
calls unset_installed_apps
and restores the proper installed apps.
django/apps/registry.py
Outdated
|
||
# If populate is called concurrently, the code below won't | ||
# guarantee that the app_config order matches the order in | ||
# INSTALLED_APPS. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm... not quite sure what I meant with this comment. Also the problem isn't concurrent calls, it's reentrant calls. The lock prevents concurrent calls when we're reaching this point.
Perhaps it should be rewritten as:
# Prevent reentrant calls to avoid running AppConfig.ready() methods twice.
django/apps/registry.py
Outdated
# INSTALLED_APPS. | ||
if self._populate_running: | ||
raise RuntimeError("populate() isn't reentrant") | ||
self._populate_running = True |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-obviously, there's no race condition here because the RLock only allows one thread to get there.
Consider explaining that in a comment, because it looks like a non-atomic compare-and-set.
tests/apps/tests.py
Outdated
with self.assertRaisesMessage(RuntimeError, "populate() isn't reentrant"): | ||
raise exceptions_queue.get(block=True, timeout=3) | ||
except Empty: | ||
self.assertFalse(True, 'Re-entering populate from the same thread did not raise an exception') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
self.fail('Re-entering ...
@@ -0,0 +1,2 @@ | |||
# -*- coding: utf-8 -*- |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove # -*- coding: utf-8 -*-
?
4baecfa
to
4fa98b6
Compare
When test are run in parallel, I cannot spawn another process for the test because |
For that kind it used to be acceptable to resort to manual testing, given that it's hard to test initialization code robustly and that such code is rarely modified. |
a2f4628
to
68506cb
Compare
The test does not run when the process executing the test is a daemon (when tests are run in parallel). More details can be found in the second commit message. I am not sure if it is worth it to include this test. It is easy to run the test using |
da389e3
to
ef454c0
Compare
ef454c0
to
cc74c2f
Compare
I don't think there are any tests that are run conditionally based on the number of parallel tests. Anyway, you can rebase your branch and remove usage of |
ea07bd1
to
82b7699
Compare
I updated the test to use a thread instead of another process, which removes the above limitation (daemonic test runner process unable to spawn an other process to run the test). I can't remember why I discarded this option back then. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It might be worth including a mention in the release notes in case anyone hits this when upgrading.
django/apps/registry.py
Outdated
# app_config should be pristine, otherwise the code below won't | ||
# guarantee that the order matches the order in INSTALLED_APPS. | ||
if self.app_configs: | ||
# A RLock is used to prevent other threads from entering this |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
used to prevent -> prevents
@@ -0,0 +1 @@ | |||
from .user_script import some_value # NOQA |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It could help to add a comment like This import triggers a first/second? call to django.setup()
.
tests/apps/tests.py
Outdated
Running django.setup() during an ongoing setup() results in a deadlock. | ||
|
||
To prevent Django's tests from hanging indefinitely if a deadlock | ||
occurs (ie. the test fails), this test is run in a separate thread. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i.e.
tests/apps/tests.py
Outdated
""" | ||
Running django.setup() during an ongoing setup() results in a deadlock. | ||
|
||
To prevent Django's tests from hanging indefinitely if a deadlock |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure if this is working properly. I reverted django/ and the test seems to hang.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test relied on mocking threading.RLock
. The original code used a Lock
, so the mock is ineffective.
Working on this, I remembered why a Thread cannot be used to run the test: it's not possible to kill the test thread when it is stuck. The only (simple) option I can think of is to run the test in another process, using multiprocessing
. However, when the tests are executed in parallel, the test processes are daemonic and cannot have children, so it is not possible to start a new process to run the test.
Do you have an idea on how to test this patch? Would it be acceptable to merge it without a corresponding test?
@@ -0,0 +1,7 @@ | |||
from django import setup | |||
|
|||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove blank line per isort (there's an isort bug that prevents the check from flagging this, but if you run isort manually it'll fix it)
tests/apps/tests.py
Outdated
@@ -1,4 +1,8 @@ | |||
import os | |||
import threading | |||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove blank line; same reason as previous comment
82b7699
to
acb058f
Compare
acb058f
to
a923f1c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If Aymeric doesn't think a test is required, I'm okay with it.
docs/releases/2.0.txt
Outdated
@@ -282,6 +282,9 @@ Miscellaneous | |||
instead of dotted Python path strings. Django favors callable references | |||
since they provide better performance and debugging experience. | |||
|
|||
* Calling `django.setup()` during a `django.setup()` call (reentrant call) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess if the current behavior is the hang forever, then this isn't backwards incompatible considering that it has no chance to break working code. (side note: doouble backticks rather than single are required to have an effect on the rendered docs)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did not see a good place for it in the release note, so I created a new Apps
section. Feel free to change it if there is a better place.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry, I didn't communicate clearly that I don't think a release note is needed. That's not really a "feature" that I'd expect anyone to care about.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! I'm removing it.
django/apps/registry.py
Outdated
# app_config should be pristine, otherwise the code below won't | ||
# guarantee that the order matches the order in INSTALLED_APPS. | ||
if self.app_configs: | ||
# A RLock prevents other threads from entering this section. The |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"An RLock"
I confirm that I find manual testing acceptable for this change. |
a923f1c
to
a97ada2
Compare
Thanks to Aymeric Augustin, Harry Percival and Tim Graham.
a97ada2
to
4a12aad
Compare
Ticket: https://code.djangoproject.com/ticket/27176