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 <noreply@anthropic.com>
This commit is contained in:
Jeremy Stretch 2026-08-05 17:10:13 -04:00
parent bab3ccd216
commit 5346dab1c3
2 changed files with 46 additions and 28 deletions

View File

@ -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,
),
]

View File

@ -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,
),
]