Skip to content

RecursionError when accessing COT models with polymorphic reverse descriptors #686

Description

@vsudevopsam

Plugin Version

0.6.0

NetBox Version

4.6.2

Python Version

3.12

Steps to Reproduce

I am encountering a RecursionError: maximum recursion depth exceeded when accessing Custom Object Types which have relational links involving polymorphic fields.

This appears to be related to the dynamic model/relation graph recursion described in #685 but the recursion enters through a different path.

In my case, NetBox starts normally. The error occurs later when acting upon a dynamically generated Custom Object model which has relational links, including relationships multiple nodes away. That is, attempting to save, modify, or delete any field on the COT or COTs on a related node upon graph construction causes infinite recursion.

The significant schema changes immediately preceding the problem were:

  • Changing internal_name values on Custom Object Type Fields
  • Explicitly setting/changing related_name values on relational fields, including polymorphic fields

No other structural schema changes were made and my schema was previously stable and functional.

Expected Behavior

Various related names can be successfully saved on polymorphic fields of COTs.

Observed Behavior

The problem appears to be associated with polymorphic relationship handling and the reverse-descriptor wiring introduced/fixed in the current plugin code. The recursion follows this path:

django.db.models.options.Options._relation_tree

django.db.models.options.Options._populate_directed_relation_graph

django.apps.registry.Apps.get_models

netbox_custom_objects.CustomObjectsPluginConfig.get_models

CustomObjectType.get_model

CustomObjectType._after_model_generation

_wire_polymorphic_reverse_descriptors

field_instance.related_object_types.all()

Django QuerySet evaluation

Query.add_q()

Query.names_to_path()

Options.get_field()

Options.fields_map

Options._get_fields

Options._relation_tree

django.apps.registry.Apps.get_models

...

The relevant portion of the traceback is:
File ".../django/db/models/options.py", line 810, in _populate_directed_relation_graph
all_models = self.apps.get_models(include_auto_created=True)
File ".../django/apps/registry.py", line 185, in get_models
result.extend(app_config.get_models(include_auto_created, include_swapped))
File ".../netbox_custom_objects/init.py", line 563, in get_models
model = custom_type.get_model()
File ".../netbox_custom_objects/models.py", line 1751, in get_model
self._after_model_generation(attrs, model)
File ".../netbox_custom_objects/models.py", line 1495, in _after_model_generation
_wire_polymorphic_reverse_descriptors(inbound_field)
File ".../netbox_custom_objects/models.py", line 3768, in _wire_polymorphic_reverse_descriptors
for ot in field_instance.related_object_types.all():
File ".../django/db/models/query.py", line 390, in iter
self._fetch_all()
...
File ".../django/db/models/options.py", line 843, in _relation_tree
return self._populate_directed_relation_graph()
File ".../django/db/models/options.py", line 810, in _populate_directed_relation_graph
all_models = self.apps.get_models(include_auto_created=True)
File ".../django/apps/registry.py", line 185, in get_models
result.extend(app_config.get_models(include_auto_created, include_swapped))
File ".../netbox_custom_objects/init.py", line 563, in get_models
model = custom_type.get_model()
...
This then repeats until Python raises maximum recursion depth exceeded. I am not sure if it is related to the number of of polymorphic fields that we are using or the structure, but I have verified that we have no structural integrity issues with the COTs themselves and the related_names that we are setting are all unique per reverse target related COT.

Suspected Cause

The immediate point of re-entry appears to be _wire_polymorphic_reverse_descriptors():

def _wire_polymorphic_reverse_descriptors(field_instance):
"""..."""
    if not field_instance.related_name:
        return

    descriptor = _make_reverse_descriptor(field_instance)
    if descriptor is None:
        return

    related_name = field_instance.related_name

    for ot in field_instance.related_object_types.all():
        target_cls = ot.model_class()

        if target_cls is None:
            continue

        existing = getattr(target_cls, related_name, None)

        if existing is not None:
            if not isinstance(
                existing,
                (
                    PolymorphicObjectReverseDescriptor,
                    PolymorphicMultiObjectReverseDescriptor,
                ),
            ):
                continue

            if not _descriptor_matches_field(existing, field_instance):
                continue

        setattr(target_cls, related_name, descriptor)

Pertinently, the recursion seems to occur while evaluating field_instance.related_object_types.all() as the ORM operation eventually requires Django to resolve model metadata and populate _relation_tree, which calls apps.get_models(). The Custom Objects plugin overrides get_models() for dynamic registration/reconstruction and consequently begins generating COT models again. I also inspected the generated model metadata for one of my affected COTs, end_user. For a reverse relation from COT id 30 (teams_voice_end_user_delegation) to COT id 25 (end_user) duplicate relation objects are reported from nbshell when examining a particular reverse relation:

relations = [
    r
    for r in model._meta.related_objects
    if r.name == "delegatees"
]
print(len(relations))

Result: 2

Both entries were the same underlying relation object:
Relation 1
object id : 139759371543776
relation class : ManyToManyRel
source field : delegatee
source model : <class 'database.models.Table30Model'>
target model : <class 'database.models.Table30Model'>
related_name : delegatees
field object id: 139759371546464

Relation 2
object id : 139759371543776
relation class : ManyToManyRel
source field : delegatee
source model : <class 'database.models.Table30Model'>
target model : <class 'database.models.Table30Model'>
related_name : delegatees
field object id: 139759371546464

I also found multiple duplicated field/relation object IDs when inspecting the result of _meta.get_fields(). I had manually assigned related_name values to a number of relational fields. A query of the COT fields shows several names being used by multiple fields. For example,

RELATED NAME: assigned_caller_id_policy
COT 28 - Field 562 - assigned_shared_device_accounts --> table27model
COT 28 - Field 563 - assigned_end_users --> table25model
COT 28 - Field 568 - assigned_groups --> table29model

RELATED NAME: auto_attendant
COT 35 - Field 682 - business_hours --> table33model
COT 35 - Field 699 - resource_accounts --> table26model

RELATED NAME: auto_attendants
COT 35 - Field 668 - tenant_groups --> tenancy.tenant_group
COT 35 - Field 669 - tenants --> tenancy.tenant
COT 35 - Field 693 - holidays_list --> table31model

I first suspected that I might have related_name conflicts. However, after verifying that all of my related names per target model were distinct, the behavior appears more closely related to the fact that a non-empty related_name causes _wire_polymorphic_reverse_descriptors() to execute, even on non-polymorphic relations:

if not field_instance.related_name:
    return

The entry path for this issue is slightly different than what is described in #685 . In that issue, the recursion described is:

get_models()
    -> get_model()
        -> _after_model_generation()
            -> ObjectType.objects.get_for_model()
                -> ObjectType.objects.create()
                    -> model._meta._reverse_on_to_one_field_names
                        -> _relation_tree
                            -> get_models()
                                -> ....

The recursion observed in this case is:

get_models()
    -> get_model()
        -> _after_model_generation()
            -> _wire_polymorphic_reverse_descriptors()
                -> related_object_types.all()
                    -> ...Django relation metadata...
                        -> _relation_tree
                            -> get_models()
                                -> ....

So, while both failures appear to involve the same fundamental re-entrancy problem, this one appears to expose another path into it through polymorphic reverse-descriptor wiring. Once I removed the related_name values from all of my polymorphic fields on COTs, my COT structure stabilized and I do not receive the error any more.

Could _wire_polymorphic_reverse_descriptors() be causing Django's relation graph to be recursively re-entered by evaluating field_instance.related_object_types.all() while get_models() is itself being invoked as part of _relation_tree construction? If so, would this need a possible re-entrancy guard similar to the proposal from #685 ?

I am happy to provide the affected COT/field definitions and a complete dependency graph if that would help reproduce the issue on your end.

Proposed Fix

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions