From 0ea353eed32f7e45fc52664ae7d0f6b06a231a98 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 5 Mar 2026 09:34:00 -0800 Subject: [PATCH] #21330 optimize object tag creation --- netbox/extras/managers.py | 65 +++++++++++++++++++++++ netbox/netbox/api/serializers/features.py | 3 ++ netbox/netbox/models/features.py | 4 +- 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 netbox/extras/managers.py diff --git a/netbox/extras/managers.py b/netbox/extras/managers.py new file mode 100644 index 000000000..79bac7bfb --- /dev/null +++ b/netbox/extras/managers.py @@ -0,0 +1,65 @@ +from django.db.models import signals +from django.db import router +from taggit.managers import _TaggableManager + +__all__ = ( + 'NetBoxTaggableManager', +) + + +class NetBoxTaggableManager(_TaggableManager): + """ + Extends taggit's _TaggableManager to replace the per-tag get_or_create loop in add() with a + single bulk_create() call, reducing SQL queries from O(N) to O(1) when assigning tags. + """ + + def add(self, *tags, through_defaults=None, tag_kwargs=None, **kwargs): + self._remove_prefetched_objects() + if tag_kwargs is None: + tag_kwargs = {} + + tag_objs = self._to_tag_model_instances(tags, tag_kwargs) + new_ids = {t.pk for t in tag_objs} + + # Determine which tags are not already assigned to this object + vals = set( + self.through._default_manager.using(db) + .values_list("tag_id", flat=True) + .filter(**self._lookup_kwargs()) + ) + new_ids -= vals + + if not new_ids: + return + + db = router.db_for_write(self.through, instance=self.instance) + signals.m2m_changed.send( + sender=self.through, + action="pre_add", + instance=self.instance, + reverse=False, + model=self.through.tag_model(), + pk_set=new_ids, + using=db, + ) + + # Use a single bulk INSERT instead of one get_or_create per tag. + lookup = self._lookup_kwargs() + self.through._default_manager.using(db).bulk_create( + [ + self.through(tag=tag, **lookup, **(through_defaults or {})) + for tag in tag_objs + if tag.pk in new_ids + ], + ignore_conflicts=True, + ) + + signals.m2m_changed.send( + sender=self.through, + action="post_add", + instance=self.instance, + reverse=False, + model=self.through.tag_model(), + pk_set=new_ids, + using=db, + ) diff --git a/netbox/netbox/api/serializers/features.py b/netbox/netbox/api/serializers/features.py index b65d719ee..6a89e7b1c 100644 --- a/netbox/netbox/api/serializers/features.py +++ b/netbox/netbox/api/serializers/features.py @@ -53,8 +53,11 @@ class TaggableModelSerializer(serializers.Serializer): def _save_tags(self, instance, tags): if tags: + # Cache tags on instance so serialize_object() can reuse them without a DB query + instance._tags = tags instance.tags.set([t.name for t in tags]) else: + instance._tags = [] instance.tags.clear() return instance diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 3ac65d894..f76d5f529 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -9,6 +9,7 @@ from django.db import models from django.db.models import Q from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from extras.managers import NetBoxTaggableManager from taggit.managers import TaggableManager from core.choices import JobStatusChoices, ObjectChangeActionChoices @@ -487,11 +488,12 @@ class JournalingMixin(models.Model): class TagsMixin(models.Model): """ Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute, - which is a `TaggableManager` instance. + which is a `NetBoxTaggableManager` instance. """ tags = TaggableManager( through='extras.TaggedItem', ordering=('weight', 'name'), + manager=NetBoxTaggableManager, ) class Meta: