Closes #22526: Avoid query timeouts when updating custom fields on a large number of objects (#22556)

This commit is contained in:
Jeremy Stretch 2026-06-26 12:22:15 -04:00 committed by GitHub
parent 2f87dab011
commit 68c1153325
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 102 additions and 20 deletions

View File

@ -6,6 +6,14 @@ from extras.choices import LogLevelChoices
# Custom fields
CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
# Maximum number of objects to update per query when provisioning, removing, or renaming custom
# field data. Bounding the number of rows touched by each statement prevents very large tables from
# exceeding the database statement timeout (JSONB updates rewrite each affected row). This value
# sits at the throughput "knee": benchmarking jsonb_set() across a 1M-row table showed throughput
# plateaus by ~5K rows/statement (raising it further yields no meaningful speedup), while keeping
# each statement orders of magnitude below a typical statement timeout.
CUSTOMFIELD_DATA_BATCH_SIZE = 5000
# ImageAttachment
IMAGE_ATTACHMENT_IMAGE_FORMATS = {
'avif': 'image/avif',

View File

@ -8,7 +8,7 @@ import jsonschema
from django import forms
from django.conf import settings
from django.core.validators import RegexValidator, ValidationError
from django.db import models
from django.db import models, transaction
from django.db.models import F, Func, Value
from django.db.models.expressions import RawSQL
from django.urls import reverse
@ -19,6 +19,7 @@ from jsonschema.exceptions import ValidationError as JSONValidationError
from core.models import ObjectType
from extras.choices import *
from extras.constants import CUSTOMFIELD_DATA_BATCH_SIZE
from extras.data import CHOICE_SETS
from extras.fields import ChoiceSetField
from netbox.context import query_cache
@ -322,6 +323,32 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
return self.choice_set.get_choice_color(value)
return None
@staticmethod
def _update_object_data(model, **update_kwargs):
"""
Apply an UPDATE to the custom_field_data of every instance of the given model in batches,
bounding the number of rows touched by each statement. A single unbounded UPDATE across
millions of rows can exceed the database statement timeout, because JSONB updates rewrite
each affected row in full. Batches are selected via keyset pagination on the primary key.
The batched updates are wrapped in a transaction so that the operation remains atomic, as
it was when performed by a single UPDATE. This guards against partially-applied data (e.g.
a renamed field landing on only some objects) should the loop be interrupted when not
already running inside a request's transaction. Batching avoids the statement timeout
regardless, as that limit applies per statement rather than per transaction.
"""
with transaction.atomic():
last_pk = 0
while True:
pks = list(
model.objects.filter(pk__gt=last_pk).order_by('pk')
.values_list('pk', flat=True)[:CUSTOMFIELD_DATA_BATCH_SIZE]
)
if not pks:
break
model.objects.filter(pk__in=pks).update(**update_kwargs)
last_pk = pks[-1]
def populate_initial_data(self, content_types):
"""
Populate initial custom field data upon either a) the creation of a new CustomField, or
@ -333,14 +360,16 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
else:
value = Value(self.default, models.JSONField())
for ct in content_types:
ct.model_class().objects.update(
custom_field_data=Func(
F('custom_field_data'),
Value([self.name]),
value,
function='jsonb_set'
if model := ct.model_class():
self._update_object_data(
model,
custom_field_data=Func(
F('custom_field_data'),
Value([self.name]),
value,
function='jsonb_set'
)
)
)
def remove_stale_data(self, content_types):
"""
@ -349,7 +378,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
"""
for ct in content_types:
if model := ct.model_class():
model.objects.update(
self._update_object_data(
model,
custom_field_data=F('custom_field_data') - self.name
)
@ -359,17 +389,19 @@ class CustomField(CloningMixin, ExportTemplatesMixin, OwnerMixin, ChangeLoggedMo
one, copying the value of the old key.
"""
for ct in self.object_types.all():
ct.model_class().objects.update(
custom_field_data=Func(
F('custom_field_data') - old_name,
Value([new_name]),
Func(
F('custom_field_data'),
function='jsonb_extract_path_text',
template=f"to_jsonb(%(expressions)s -> '{old_name}')"
),
function='jsonb_set')
)
if model := ct.model_class():
self._update_object_data(
model,
custom_field_data=Func(
F('custom_field_data') - old_name,
Value([new_name]),
Func(
F('custom_field_data'),
function='jsonb_extract_path_text',
template=f"to_jsonb(%(expressions)s -> '{old_name}')"
),
function='jsonb_set')
)
def clean(self):
super().clean()

View File

@ -1,6 +1,7 @@
import datetime
import json
from decimal import Decimal
from unittest.mock import patch
from django.core.exceptions import ValidationError
from django.test import tag
@ -628,6 +629,47 @@ class CustomFieldTestCase(TestCase):
self.assertNotIn('field1', site.custom_field_data)
self.assertEqual(site.custom_field_data['field2'], FIELD_DATA)
@patch('extras.models.customfields.CUSTOMFIELD_DATA_BATCH_SIZE', 2)
def test_batched_object_data_updates(self):
"""
Provisioning, renaming, and removing custom field data is applied in batches. Use a small
batch size to ensure the data on every object is updated across multiple batches.
"""
# The existing sites (created in setUpTestData) span multiple batches of size 2
site_count = Site.objects.count()
self.assertGreater(site_count, 2)
# Provisioning: a default value is populated onto every existing object
cf = CustomField.objects.create(
name='batched_field',
type=CustomFieldTypeChoices.TYPE_TEXT,
default='foo'
)
cf.object_types.set([self.object_type])
self.assertEqual(
Site.objects.filter(custom_field_data__batched_field='foo').count(),
site_count
)
# Renaming: the key is renamed on every existing object, preserving its value
cf.name = 'renamed_field'
cf.save()
self.assertEqual(
Site.objects.filter(custom_field_data__renamed_field='foo').count(),
site_count
)
self.assertEqual(
Site.objects.filter(custom_field_data__has_key='batched_field').count(),
0
)
# Removal: the key is stripped from every existing object when the field is deleted
cf.delete()
self.assertEqual(
Site.objects.filter(custom_field_data__has_key='renamed_field').count(),
0
)
def test_default_value_validation(self):
choiceset = CustomFieldChoiceSet.objects.create(
name="Test Choice Set",