Closes #658: Harden polymorphic multiobject through-model registration against concurrent creation/reads - #648
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>
|
@claude /review and limit response to potential problems of medium or higher severity. |
|
Claude finished @bctiemann's task in 3m 52s —— View job PR Review: #648 — Fix polymorphic multiobject delete race
AssessmentThe production fix in Medium: Thread completion not asserted after timed
|
reader_done.wait(timeout=2) inside ordered_register_model always times out with the fix applied (R is blocked on _global_lock and can never signal it), taxing every CI run by a flat 2 seconds. The duration only bounds an unavoidable wait; correctness doesn't depend on it, since R's ability to run concurrently is decided by lock state, not by wall-clock timing. Confirmed via 5 runs each way: cutting it to 0.5s still passes reliably with the fix and still fails reliably without it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
I fixed item 2 (reduced the timeout to avoid adding to the CI runtime), but left the other two alone—especially item 3 which basically would amount to neutralizing the very race-condition fix we're trying to implement. |
netbox-core's main and feature branches currently produce different query counts for the shared list/permission-check code path these tests exercise, and this baseline can only hold one number per key -- so a plugin PR's baseline necessarily goes stale on whichever ref it wasn't last tuned against. CI's own "tests (main)" run on this branch's current HEAD observed 39/45/31/32 against the recorded 41/47/33/34; PR #648 hit the same issue independently and already updated its own baseline to matching (mostly identical) numbers. Updating to the CI-observed values here rather than guessing or re-deriving them locally, since local single-test runs don't reproduce the same accumulated app-registry/cache state a full suite run does and gave unreliable numbers when tried. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pheus
left a comment
There was a problem hiding this comment.
Thanks for taking this on. The locking change itself looks reasonable, but I’d like the regression to cover the actual Custom Object lifecycle before approval. The current test uses a persisted field and calls the private schema helper directly, then verifies deletion through a path that repairs the FK first. I’ve left inline notes on matching the reported save and confirmation workflow, plus one on making the thread assertions deterministic.
* test_forced_registration_interleaving_stays_consistent: assert both threads actually completed after join(), rather than letting a join() timeout silently leave the result dicts empty and the assertions below vacuously pass. * Cover the delete-confirmation GET (issue #640, step 4): obj.delete() realigns each through's "source" FK to type(self) before Django's collector runs, which would silently paper over a lingering registry mismatch that a plain GET -- the actual reported UI path -- does not repair. * Add a regression through the public field-save path (CustomObjectTypeField.objects.create()) with the reported two-type Custom Object setup, instead of only ever starting from an already-persisted field and calling create_polymorphic_m2m_table() directly. A deterministic (mocked apps.register_model()) version of this specific scenario was attempted and abandoned after it produced a genuine deadlock in testing: two threads targeting the identical through table can block each other at the Postgres DDL level while also contending for CustomObjectType._global_lock. Real thread-scheduling concurrency, exercised via 12 looping readers (mirroring the existing single-type test), reaches the same code path safely.
…ultiobject-delete-race # Conflicts: # netbox_custom_objects/tests/test_schema_operations.py
pheus
left a comment
There was a problem hiding this comment.
Thanks for the additional coverage. I think this is now down to two blockers: the regression still does not demonstrate #640’s fresh-process FieldDoesNotExist, and the lock currently spans transactional DDL, which introduces a credible deadlock path.
create_polymorphic_m2m_table() held _global_lock across both the build+register+repoint step AND the table-existence probe/DDL. A concurrent CustomObjectTypeField.save() for the same field also calls CustomObjectType.clear_model_cache(), which acquires this same lock: if the lock stayed held across schema_editor.create_model() (an uncommitted CREATE TABLE inside this save()'s own transaction), a second thread blocked on the lock -- itself stuck at the Postgres level waiting on the first thread's uncommitted transaction for the same physical table -- would prevent the first thread from ever reaching clear_model_cache() to commit. Neither side could then make progress. Scope the lock to just the build+register+repoint step; release it before the table-existence probe/DDL runs. Confirmed via a new regression test (two threads double-submitting field creation for the identical (custom_object_type, name)): hangs against the previous, wider-scoped lock (reproduced the exact deadlock signature in pg_stat_activity -- one thread idle-in-transaction waiting on the lock, the other actively blocked on Postgres waiting for the first's uncommitted CREATE TABLE), completes in ~1.5s with the fix.
|
Thanks -- both addressed. On the deadlock ( On #640's actual symptom: I tried to reproduce it directly -- a fresh single process, no concurrency, self-referencing 2-3 type polymorphic multiobject field, then a real Rather than keep asserting this closes #640 without being able to demonstrate the reported failure, I've rescoped the PR: title/description updated, dropped the |
|
Because this PR does not actually fix #640 directly, it's not completely clear to me whether we should merge this anyway because it's not in response to any specific raised bug; but it does at least eliminate a possible path for the deadlock or multi-worker race condition to manifest. We can keep #640 open and request further reproduction details. |
pheus
left a comment
There was a problem hiding this comment.
Thanks for separating these findings into #658.
I’m happy to approve this. I’ve left two small documentation notes: please update the remaining #640 references to #658 and correct the test explanation that still describes the previous, wider lock scope. Please also ensure the PR and final merge message close #658 rather than #640.
…st docstring The registration-before-repoint race and its regression test comments were still citing #640 (the unreproduced report this PR doesn't fix) instead of #658 (the actual bug this PR fixes and closes). Left the one reference to #640 that correctly attributes the delete-confirmation-GET test coverage to that issue's own numbered reproduction steps, which #658 doesn't have. Also corrected test_forced_registration_interleaving_stays_consistent's docstring: it described _global_lock as held "for that whole call," which was true before the lock was narrowed to stop before the table-existence probe/DDL. Shortened to describe only what the test itself asserts, with a pointer to #658 for the full analysis.
|
Thanks! Both addressed in 7a47878:
Left the double-submit deadlock test's potential-hang risk as-is per your note that it's not blocking -- agreed a safely-releasable interleaving or subprocess isolation would be the right way to harden it, but that's more surface area than this PR needs. |
Closes: #658
Related to: #640
Scope (updated per review)
This PR does not close #640. I investigated whether it does and could not reproduce #640's reported symptom (a deterministic
FieldDoesNotExiston a fresh, single, unconcurrent process) on currentmain-- see "Investigation of #640" below. This PR instead fixes two independently real, verified concurrency bugs in the same function that I found while investigating (now filed separately as #658), and is scoped as registry-race hardening. #640 remains open pending further reproduction details.Summary
MultiObjectFieldType.create_polymorphic_m2m_table()(called once, when a polymorphicmultiobjectfield is first created) built and registered a through-model class with Django's app registry, and only afterward repointed itssourceFK at the caller's model class -- all without holdingCustomObjectType._global_lock. A concurrentget_model(no_cache=True)call (lock-protected only on its own side) 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. Fixed by holding_global_lockacross the build+register+repoint sequence.(name, custom_object_type)UniqueConstraint. Whichever thread'sschema_editor.create_model()runs second blocks at the Postgres level waiting on the first thread's uncommittedCREATE TABLE(same table name) to resolve -- but the first thread's ownsave()needs_global_lockagain inclear_model_cache()before it can commit and release that wait. Fixed by narrowing the lock to stop before the DDL.Investigation of #640
Per review, I tried to reproduce #640's actual reported symptom directly: a fresh, single Python process, no concurrency, self-referencing polymorphic multiobject field with 2-3 related types,
FieldDoesNotExist/model missing the field on the very firstget_model()call. I could not reproduce it on currentmain:GETrequests against both the object detail page and the delete-confirmation page (the exact reported repro steps) -- both returned 200, no crash, both with and without this PR's lock fix.model._meta.get_fields()does not listdepends_onfor a polymorphic multiobject field regardless -- these fields are implemented as a plain descriptor (PolymorphicM2MDescriptor), not a real DjangoField, so they were never expected to appear there. That's consistent with the snippet in Deleting a Custom Object with a multiobject field raises ValueError (recurrence of #477 in v0.6.0) #640's own report, but isn't itself evidence of breakage (obj.depends_on.set(...)and the actual views work fine via descriptor access).Since
mainhas moved substantially since v0.6.0 (the version #640 was filed against) via other fixes, it's plausible #640's actual root cause was already resolved by an unrelated change, or is specific to the reporter's serving environment (NGINX Unit, Python 3.14, multi-worker-process). I don't have a way to confirm either without reproduction access. Given I can't produce a regression that demonstrates #640's actual symptom, I'm leaving #640 open rather than closing it here, per the suggested alternative.Test plan
PolymorphicMultiObjectConcurrencyTestCase.test_forced_registration_interleaving_stays_consistent: deterministically forces the registration-before-repoint race via a mockedapps.register_model()hook. Fails against the unfixed code (source FK /get_model()class mismatch), passes with the fix. Asserts both threads actually completed (not just timed out) before checking results.PolymorphicMultiObjectConcurrencyTestCase.test_concurrent_double_submit_does_not_deadlock: two threads independently callCustomObjectTypeField.objects.create()for the identical(custom_object_type, name). Hangs against the wide-lock version (confirmed viapg_stat_activity: one thread idle-in-transaction waiting on the Python lock, the other actively blocked on Postgres waiting for the first's uncommittedCREATE TABLE); completes in ~1.5s with the narrowed lock.PolymorphicMultiObjectConcurrencyTestCase.test_field_creation_racing_concurrent_readers_yields_consistent_through_model/..._with_two_type_setup_...: real-concurrency variants (12 loopingget_model()readers against real field creation), including the reported two-type Custom Object setup, through the publicCustomObjectTypeField.objects.create()path.GETcoverage: the reported UI path (issue Deleting a Custom Object with a multiobject field raises ValueError (recurrence of #477 in v0.6.0) #640, step 4) doesn't callobj.delete(), which itself realigns the through's FK before Django's collector runs -- a plainGETexercises the unrepaired state directly.ruff checkclean on both changed files.test_schema_operations.pypasses (4 pre-existing, unrelated errors from anetbox_branchingimport artifact under the non-branching test configuration, present on unmodifiedmaintoo).