Skip to content

Closes #658: Harden polymorphic multiobject through-model registration against concurrent creation/reads - #648

Merged
bctiemann merged 8 commits into
mainfrom
640-fix-polymorphic-multiobject-delete-race
Aug 14, 2026
Merged

Closes #658: Harden polymorphic multiobject through-model registration against concurrent creation/reads#648
bctiemann merged 8 commits into
mainfrom
640-fix-polymorphic-multiobject-delete-race

Conversation

@bctiemann

@bctiemann bctiemann commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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 FieldDoesNotExist on a fresh, single, unconcurrent process) on current main -- 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 polymorphic multiobject field is first created) built and registered a through-model class with Django's app registry, and only afterward repointed its source FK at the caller's model class -- 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, 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. Fixed by holding _global_lock across the build+register+repoint sequence.
  • That same lock, if held across the table-existence probe/DDL that follows, creates a different real deadlock: two threads racing to create the same field (e.g. a retried request) each build+register a through model for the same physical table before either knows which one will win the (name, custom_object_type) UniqueConstraint. Whichever thread's schema_editor.create_model() runs second blocks at the Postgres level waiting on the first thread's uncommitted CREATE TABLE (same table name) to resolve -- but the first thread's own save() needs _global_lock again in clear_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 first get_model() call. I could not reproduce it on current main:

  • Created a COT with a self-referencing 3-type polymorphic multiobject field in one process, then in a separate, fresh process (no shared in-memory state) issued real GET requests 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 list depends_on for a polymorphic multiobject field regardless -- these fields are implemented as a plain descriptor (PolymorphicM2MDescriptor), not a real Django Field, 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 main has 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 mocked apps.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 call CustomObjectTypeField.objects.create() for the identical (custom_object_type, name). Hangs against the wide-lock version (confirmed via pg_stat_activity: one thread idle-in-transaction waiting on the Python lock, the other actively blocked on Postgres waiting for the first's uncommitted CREATE 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 looping get_model() readers against real field creation), including the reported two-type Custom Object setup, through the public CustomObjectTypeField.objects.create() path.
  • Delete-confirmation GET coverage: 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 call obj.delete(), which itself realigns the through's FK before Django's collector runs -- a plain GET exercises the unrepaired state directly.
  • ruff check clean on both changed files.
  • Full test_schema_operations.py passes (4 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>
@bctiemann

Copy link
Copy Markdown
Contributor Author

@claude /review and limit response to potential problems of medium or higher severity.

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @bctiemann's task in 3m 52s —— View job


PR Review: #648 — Fix polymorphic multiobject delete race

  • Gathering context
  • Reading changed files
  • Analyzing for medium+ severity issues

Assessment

The production fix in field_types.py is correct and well-reasoned. The three findings below are all in the test code.


Medium: Thread completion not asserted after timed join() — spurious pass risk

test_forced_registration_interleaving_stays_consistent (test_schema_operations.py, near line ~440):

t_w.join(timeout=10)
t_r.join(timeout=10)

Neither call asserts not t_w.is_alive() / not t_r.is_alive(). If either thread deadlocks (e.g. a DB-level lock) or hits an unexpected exception before writing to writer_result/reader_result, the joins time out silently and the subsequent assertions all pass: writer_result = {} has no 'error' key, reader_result = {} has no 'error' key, and the final final_model/through_model assertions use a fresh get_model() call that's unaffected by the stuck threads.

The fix is to add after each join:

self.assertFalse(t_w.is_alive(), "writer thread did not complete within timeout")
self.assertFalse(t_r.is_alive(), "reader thread did not complete within timeout")

Fix this →


Medium: Deterministic test always burns a 2-second wall-clock delay per CI run

Inside ordered_register_model (the mock side-effect, called on thread W):

reader_done.wait(timeout=2)  # always times out with the fix applied

With the fix, W holds _global_lock for the entire create_polymorphic_m2m_table call. R's get_model(no_cache=True) blocks on _global_lock at _after_model_generation and never signals reader_done, so the 2-second timeout fires unconditionally on every run. The test comment acknowledges this is expected, but 2 seconds per run is a persistent CI tax.

Reducing the timeout to 0.5 or even 0.1 seconds preserves the intent ("give R a window; with the fix it won't get one") while cutting the CI cost by 4–20×.

Fix this →


Medium: _global_lock now held during table_names() DB query

Inside create_polymorphic_m2m_table (field_types.py ~line 1821):

with CustomObjectType._global_lock:
    ...
    with conn.cursor() as cursor:
        existing_tables = conn.introspection.table_names(cursor)  # full schema scan
        if table_name not in existing_tables:
            schema_editor.create_model(through)

table_names() fetches all table names from information_schema — a full round-trip that can take tens to hundreds of milliseconds on an instance with many tables. While this lock is held, any concurrent get_model() caller that needs _global_lock for _after_model_generation blocks completely.

Field creation is rare (only on the first save of a polymorphic multiobject field), so this is unlikely to be observable in practice. But it's a latency cliff if it ever coincides with high get_model() traffic. No code change strictly required, but worth tracking if get_model() latency regressions appear.


Production code fix — no issues found

The change to field_types.py correctly closes the race: get_polymorphic_through_model + FK repoint now share _global_lock with _after_model_generation's own reuse-or-create path. Since _global_lock is an RLock, re-entrant acquisition from the same thread is safe. No deadlock risk: the writer acquires the Python lock after opening the DB transaction/schema_editor, and the reader holds no DB locks when it reaches _global_lock. The idempotency guard (table_names check) is unchanged in behavior.

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>
@bctiemann

Copy link
Copy Markdown
Contributor Author

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.

@bctiemann
bctiemann requested review from a team and pheus and removed request for a team August 11, 2026 17:06
bctiemann added a commit that referenced this pull request Aug 12, 2026
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 pheus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread netbox_custom_objects/tests/test_schema_operations.py
Comment thread netbox_custom_objects/tests/test_schema_operations.py
Comment thread netbox_custom_objects/tests/test_schema_operations.py
* 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
@bctiemann
bctiemann requested a review from pheus August 14, 2026 01:12

@pheus pheus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread netbox_custom_objects/tests/test_schema_operations.py Outdated
Comment thread netbox_custom_objects/field_types.py
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.
@bctiemann bctiemann changed the title Fixes #640: Serialize polymorphic multiobject through-model creation against concurrent readers Harden polymorphic multiobject through-model registration against concurrent creation/reads Aug 14, 2026
@bctiemann

Copy link
Copy Markdown
Contributor Author

Thanks -- both addressed.

On the deadlock (field_types.py:1808): confirmed, and reproduced it directly -- two threads double-submitting field creation for the same (custom_object_type, name) hung a real test run, and pg_stat_activity showed exactly the cycle you described: one thread idle-in-transaction waiting on _global_lock (needed again in clear_model_cache() to commit), the other actively blocked on Postgres waiting for the first thread's uncommitted CREATE TABLE on the same physical table. Narrowed the lock to just the build+register+repoint step; the table-existence probe/DDL now runs after it's released. Added test_concurrent_double_submit_does_not_deadlock, which hangs against the old wide-lock version and passes in ~1.5s with the fix.

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 GET to both the object detail page and the delete-confirmation page from a separate fresh process. Both returned 200 on current main, with or without this PR's fix. I couldn't get FieldDoesNotExist to happen at all this way.

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 Closes: keyword, and left #640 open. What's left is two independently real, verified concurrency bugs in the same function (the original registration-before-repoint race, plus the deadlock above) -- registry-race hardening, not a fix for #640's specific report. Happy to revisit #640 itself separately if reproduction access or more detail becomes available; my best guess is it's either already fixed by something else merged since v0.6.0, or specific to the reporter's serving environment (NGINX Unit, Python 3.14, multi-worker-process), neither of which I can confirm from here.

@bctiemann

Copy link
Copy Markdown
Contributor Author

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.

@bctiemann

Copy link
Copy Markdown
Contributor Author

Filed the two concurrency bugs this PR fixes as a separate issue, #658, and updated this PR to close it instead of referencing #640 directly. #640 stays open pending further reproduction details, per the discussion above.

@pheus pheus changed the title Harden polymorphic multiobject through-model registration against concurrent creation/reads Closes #658: Harden polymorphic multiobject through-model registration against concurrent creation/reads Aug 14, 2026

@pheus pheus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread netbox_custom_objects/tests/test_schema_operations.py
Comment thread netbox_custom_objects/field_types.py Outdated
Comment thread netbox_custom_objects/tests/test_schema_operations.py
…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.
@bctiemann

Copy link
Copy Markdown
Contributor Author

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.

@bctiemann
bctiemann merged commit 725e866 into main Aug 14, 2026
12 checks passed
@bctiemann
bctiemann deleted the 640-fix-polymorphic-multiobject-delete-race branch August 14, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants