Skip to content

Fixes #640: Serialize polymorphic multiobject through-model creation against concurrent readers - #648

Open
bctiemann wants to merge 2 commits into
mainfrom
640-fix-polymorphic-multiobject-delete-race
Open

Fixes #640: Serialize polymorphic multiobject through-model creation against concurrent readers#648
bctiemann wants to merge 2 commits into
mainfrom
640-fix-polymorphic-multiobject-delete-race

Conversation

@bctiemann

@bctiemann bctiemann commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Closes: #640

Summary

  • Fixes a race in MultiObjectFieldType.create_polymorphic_m2m_table() (called once, when a polymorphic multiobject field is first created) where the through-model class was built and registered with Django's app registry, and only afterward had its source FK repointed at the caller's model class — all without holding CustomObjectType._global_lock.
  • A concurrent get_model(no_cache=True) call (e.g. from a request rendering the delete-confirmation page, or a raw DELETE) is lock-protected only on its own side. It could land in that window, find the through model already registered, and repoint source at its own (different, but table-equivalent) model instance instead — leaving the through's FK and whatever get_model() subsequently caches pointing at two different Python classes for the same table.
  • That class-identity mismatch is what produced the reported ValueError: Cannot query "X": Must be "TableYModel" instance. and RecursionError — a recurrence of ValueError: Cannot query "<object_name>": Must be "Table4Model" instance when deleting Custom Object via UI #477 in a different code path.
  • The fix wraps the build+register+repoint sequence in the same global lock _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_table into the same CustomObjectType._global_lock construct 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

  • Added PolymorphicMultiObjectConcurrencyTestCase.test_forced_registration_interleaving_stays_consistent, which deterministically forces the exact writer/reader interleaving via a mocked apps.register_model() hook (rather than relying on real thread-scheduling luck, which does not reliably land inside this narrow window).
  • Verified: 5/5 runs fail against the unfixed code (assertion mismatch between the through model's source FK class and what get_model() returns), 5/5 runs pass with the fix.
  • ruff check clean on both changed files.
  • Full test_deletion suite passes (2 pre-existing, unrelated errors from a netbox_branching import artifact under the non-branching test configuration, present on unmodified main too).

bctiemann and others added 2 commits August 7, 2026 12:20
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deleting a Custom Object with a multiobject field raises ValueError (recurrence of #477 in v0.6.0)

1 participant