From 5346dab1c3f3d6075f400c46eef1e1da0f02a827 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 5 Aug 2026 17:10:13 -0400 Subject: [PATCH] Move the execution_time backfill into its own non-atomic migration Batching the backfill bounded statement size but not lock duration: sharing a transaction with the AddField meant the ACCESS EXCLUSIVE lock from ALTER TABLE was held for the whole run, which is exactly the case the batching was meant to help. 0025 goes back to adding the column only, and the backfill moves to 0026 with atomic = False so the lock is released first. The backfill now also skips rows which already have a value, making it idempotent and letting an interrupted run simply be resumed. As a separate migration it additionally reaches installations which had already applied 0025, rather than silently leaving their historical jobs unpopulated. Co-Authored-By: Claude Opus 5 --- .../migrations/0025_add_job_execution_time.py | 28 ----------- .../0026_populate_job_execution_time.py | 46 +++++++++++++++++++ 2 files changed, 46 insertions(+), 28 deletions(-) create mode 100644 netbox/core/migrations/0026_populate_job_execution_time.py diff --git a/netbox/core/migrations/0025_add_job_execution_time.py b/netbox/core/migrations/0025_add_job_execution_time.py index 049bc7ab2..f8c58d9ed 100644 --- a/netbox/core/migrations/0025_add_job_execution_time.py +++ b/netbox/core/migrations/0025_add_job_execution_time.py @@ -1,28 +1,4 @@ from django.db import migrations, models -from django.db.models import DurationField, ExpressionWrapper, F - -BATCH_SIZE = 5000 - - -def populate_execution_time(apps, schema_editor): - """ - Populate execution_time for existing jobs which have both a start and completion time recorded. - Updates are performed in batches, as installations which retain job history indefinitely can - accumulate a very large number of rows. - """ - Job = apps.get_model("core", "Job") - queryset = Job.objects.filter(started__isnull=False, completed__isnull=False) - execution_time = ExpressionWrapper(F("completed") - F("started"), output_field=DurationField()) - - last_pk = 0 - while True: - pks = list( - queryset.filter(pk__gt=last_pk).order_by("pk").values_list("pk", flat=True)[:BATCH_SIZE] - ) - if not pks: - break - Job.objects.filter(pk__in=pks).update(execution_time=execution_time) - last_pk = pks[-1] class Migration(migrations.Migration): @@ -37,8 +13,4 @@ class Migration(migrations.Migration): name="execution_time", field=models.DurationField(blank=True, editable=False, null=True), ), - migrations.RunPython( - code=populate_execution_time, - reverse_code=migrations.RunPython.noop, - ), ] diff --git a/netbox/core/migrations/0026_populate_job_execution_time.py b/netbox/core/migrations/0026_populate_job_execution_time.py new file mode 100644 index 000000000..e74182024 --- /dev/null +++ b/netbox/core/migrations/0026_populate_job_execution_time.py @@ -0,0 +1,46 @@ +from django.db import migrations +from django.db.models import DurationField, ExpressionWrapper, F + +BATCH_SIZE = 5000 + + +def populate_execution_time(apps, schema_editor): + """ + Populate execution_time for existing jobs which have both a start and completion time recorded. + Updates are performed in batches, as installations which retain job history indefinitely can + accumulate a very large number of rows. Rows which already have a value are skipped, so that an + interrupted run can simply be resumed. + """ + Job = apps.get_model("core", "Job") + queryset = Job.objects.filter( + started__isnull=False, completed__isnull=False, execution_time__isnull=True + ) + execution_time = ExpressionWrapper(F("completed") - F("started"), output_field=DurationField()) + + last_pk = 0 + while True: + pks = list( + queryset.filter(pk__gt=last_pk).order_by("pk").values_list("pk", flat=True)[:BATCH_SIZE] + ) + if not pks: + break + Job.objects.filter(pk__in=pks).update(execution_time=execution_time) + last_pk = pks[-1] + + +class Migration(migrations.Migration): + # The backfill is deliberately kept out of the migration which adds the column, so that the + # ACCESS EXCLUSIVE lock taken by ALTER TABLE is not held for its duration. Running without a + # wrapping transaction is what allows the batching above to bound the work actually held open. + atomic = False + + dependencies = [ + ("core", "0025_add_job_execution_time"), + ] + + operations = [ + migrations.RunPython( + code=populate_execution_time, + reverse_code=migrations.RunPython.noop, + ), + ]