Fixes #629: Delete stale model registration before regenerating, not after - #676
Fixes #629: Delete stale model registration before regenerating, not after#676bctiemann wants to merge 3 commits into
Conversation
…after
Root cause: CustomObjectType.get_model() deleted the old apps.all_models
entry for a COT's dynamic model *after* generate_model() built the
replacement class, not before.
TagsMixin's 'tags' field resolves its 'through' model lazily: taggit's
contribute_to_class() runs mid-construction, while Django's
ModelBase.__new__() is still adding fields to the new class -- well before
the new class itself gets registered. It calls lazy_related_operation(),
which treats the *owning class* as a dependency, resolved by looking it
up in apps.all_models under this model's name (not by the literal class
object passed in). With the *old* class still registered under that name
at that exact moment, the lookup resolved immediately against the stale,
about-to-be-replaced class instead of deferring -- so the new class's own
'tags' field's post_through_setup() never ran against itself, and it
never got a 'tagged_items' GenericRelation. Without that GenericRelation,
Django's deletion collector has no way to cascade-delete a custom
object's TaggedItem rows on delete.
Symptom: deleting a tagged custom object left its TaggedItem row behind.
The tag's detail page reported a nonzero item count, but filtering by
that tag returned zero results, since the referenced object no longer
existed ("phantom" tagged objects).
Fix: delete the stale registry entry *before* calling generate_model()
instead of after, so generate_model()'s own type() call is what
correctly registers (and satisfies the lazy dependency for) the new
class. The now-redundant explicit apps.register_model() call afterward
is guarded to skip re-registering when generate_model() already did so,
avoiding a spurious "already registered" RuntimeWarning.
Adds PhantomTaggedObjectsTestCase covering: the generated model actually
gets a 'tagged_items' GenericRelation; direct .delete(), queryset bulk
.delete(), and delete via a freshly-regenerated class all clean up the
TaggedItem row; and filtering by tag after deleting a tagged object
returns the correct count. All 5 fail without the fix and pass with it.
Full suite verified in a clean venv: 1154 tests, 0 failures/errors,
8 skipped.
Condense the get_model() explanatory comments and the test class docstring/per-test docstrings down to the essential why, per repo style.
|
@claude /review and limit response to actionable issues. |
|
Claude finished @bctiemann's task in 2m 39s —— View job Code ReviewThe fix is correct and the root cause explanation is accurate. Actionable issues below. 1. Wider race window during concurrent model lookup (medium) Moving the deletion to lines 1745–1749 (before During this window, any concurrent thread calling This is likely acceptable in practice (the window is still short and the existing design accepts some concurrency looseness), but it should be documented explicitly alongside the existing concurrency comment. 2. # lines 1747-1749 — models.py
with _suppress_clear_cache():
if model_key in apps.all_models[APP_LABEL]:
del apps.all_models[APP_LABEL][model_key]
Remove unnecessary suppressor → 3. Redundant
Tests — coverage is solid. The five cases in |
- Document the wider concurrent-lookup race window the #629 fix introduces: pre-deleting the stale registry entry before generate_model() (rather than after) means the window where apps.all_models has no entry for this model name now spans the whole generation call, not a couple of lines, so an unrelated concurrent apps.get_model() call can transiently see it as unregistered. - Remove the unnecessary _suppress_clear_cache() around the plain dict deletion -- del never calls apps.clear_cache(), so the suppressor there was misleading; it is only needed around apps.register_model(). - Compute model_key once, before the branch_id check, instead of twice (once before generate_model(), once after). Full suite verified in a clean venv: 1154 tests, 0 failures/errors, 8 skipped.
|
Closes: #629
Summary
Reproduces and fixes #629 (Phantom Tagged Objects): deleting a tagged custom object left its
TaggedItemrow behind. The tag's detail page reported a nonzero item count, but filtering by that tag returned zero results, since the referenced object no longer existed.Root cause
CustomObjectType.get_model()deleted the oldapps.all_modelsregistry entry for a COT's dynamic model aftergenerate_model()had already built the replacement class, not before.TagsMixin'stagsfield resolves itsthroughmodel lazily: taggit'scontribute_to_class()runs mid-construction, while Django'sModelBase.__new__()is still adding fields to the new class — well before the new class itself gets registered. It callslazy_related_operation(), which treats the owning class as a dependency, resolved by looking it up inapps.all_modelsunder the model's name (not the literal class object passed in). Since the old class was still registered under that name at that exact moment, the lookup resolved immediately against the stale, about-to-be-replaced class instead of deferring — so the new class's owntagsfield'spost_through_setup()never ran against itself, and it never got atagged_itemsGenericRelation. Without thatGenericRelation, Django's deletion collector has no way to cascade-delete a custom object'sTaggedItemrows on delete — regardless of whether the object is deleted via a single instance's.delete(), a bulkqueryset.delete(), or a freshly-regenerated class.Fix
Delete the stale registry entry before calling
generate_model()instead of after, sogenerate_model()'s owntype()call is what correctly registers (and satisfies the lazy dependency for) the new class. The now-redundant explicitapps.register_model()call afterward is guarded to skip re-registering whengenerate_model()already did so, avoiding a spurious "already registered"RuntimeWarning.Testing
New
PhantomTaggedObjectsTestCaseintest_models.py:test_generated_model_has_tagged_items_generic_relation— the root cause: the generated model must actually have atagged_itemsGenericRelation.test_direct_delete_removes_tagged_item/test_queryset_bulk_delete_removes_tagged_item/test_delete_after_model_regeneration_removes_tagged_item— all three deletion paths must clean up theTaggedItemrow.test_filtering_by_tag_matches_actual_object_count— the user-visible symptom: filtering by tag after deleting a tagged object must return the correct count.All 5 fail without the fix (reproducing the exact reported symptom) and pass with it.
Full suite verified in a clean venv (fresh install, no stray environment state): 1154 tests, 0 failures/errors, 8 skipped.