Fixes #640: Serialize polymorphic multiobject through-model creation against concurrent readers - #648
Open
bctiemann wants to merge 2 commits into
Open
Fixes #640: Serialize polymorphic multiobject through-model creation against concurrent readers#648bctiemann wants to merge 2 commits into
bctiemann wants to merge 2 commits into
Conversation
…against concurrent readers
create_polymorphic_m2m_table() built and registered a fresh through-model
class and only afterward repointed its "source" FK at the caller's model,
all without holding CustomObjectType._global_lock. A concurrent
get_model(no_cache=True) call -- lock-protected only on its own side --
could land in that window, find the through model already registered, and
repoint "source" at its own (different) model instance instead, leaving the
through's FK and whatever get_model() subsequently caches pointing at two
different classes for the same table. That produced the intermittent
ValueError ("Cannot query 'X': Must be 'TableYModel' instance.") and
RecursionError reported here (recurrence of #477).
Wrapping the build+register+repoint sequence in the same global lock closes
the gap. Added a deterministic regression test that forces a writer thread
(create_polymorphic_m2m_table) and a reader thread (get_model) into the
exact interleaving via a mocked apps.register_model(), rather than relying
on real thread-scheduling luck to land inside the race window.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ments PolymorphicMultiObjectConcurrencyTestCase exercises through-model registration during polymorphic multiobject field creation (a schema operation), not deletion logic -- it only lived in test_deletion.py because the investigation started from the bug's delete-time symptom. Moved it next to the other schema-creation/registry tests it actually belongs with. Also trimmed the docstrings and inline comments, which had grown into multi-paragraph explanations restating the same points -- cut to the essential why (what's already locked, what isn't, and why the fixed case times out rather than deadlocking) without re-deriving the whole investigation inline. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes: #640
Summary
MultiObjectFieldType.create_polymorphic_m2m_table()(called once, when a polymorphicmultiobjectfield is first created) where the through-model class was built and registered with Django's app registry, and only afterward had itssourceFK repointed at the caller's model class — all without holdingCustomObjectType._global_lock.get_model(no_cache=True)call (e.g. from a request rendering the delete-confirmation page, or a rawDELETE) is lock-protected only on its own side. It could land in that window, find the through model already registered, and repointsourceat its own (different, but table-equivalent) model instance instead — leaving the through's FK and whateverget_model()subsequently caches pointing at two different Python classes for the same table.ValueError: Cannot query "X": Must be "TableYModel" instance.andRecursionError— a recurrence of ValueError: Cannot query "<object_name>": Must be "Table4Model" instance when deleting Custom Object via UI #477 in a different code path._after_model_generation()'s caller already uses, closing the gap.Note: The only material change here is to move the schema probe in
create_polymorphic_m2m_tableinto the sameCustomObjectType._global_lockconstruct that has remedied similar race conditions in the past. The unit tests that exercise this operation have very long docstrings/comments, but this is after significant trimming-down already; I feel the context the documentation provides will be helpful in diagnosing any future similar issues.Root cause detail
_after_model_generation()'s own reuse-or-create check for polymorphic through models is already fully serialized —CustomObjectType.get_model()wraps that whole call in_global_lock, so two concurrent readers regenerating the same COT never race each other there. The actual gap was on the writer side (field creation), which never took that lock at all.Test plan
PolymorphicMultiObjectConcurrencyTestCase.test_forced_registration_interleaving_stays_consistent, which deterministically forces the exact writer/reader interleaving via a mockedapps.register_model()hook (rather than relying on real thread-scheduling luck, which does not reliably land inside this narrow window).sourceFK class and whatget_model()returns), 5/5 runs pass with the fix.ruff checkclean on both changed files.test_deletionsuite passes (2 pre-existing, unrelated errors from anetbox_branchingimport artifact under the non-branching test configuration, present on unmodifiedmaintoo).