Skip to content

Commit 702f802

Browse files
authored
Merge pull request learningequality#5986 from rtibbles/widen_file_size_bigint
Almost zero-downtime migration tooling; widen File.file_size to bigint (expand stage)
2 parents 8a56525 + 2c079c0 commit 702f802

16 files changed

Lines changed: 582 additions & 3 deletions

.github/workflows/pythontest.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ jobs:
6262
- 6379:6379
6363
steps:
6464
- uses: actions/checkout@v6
65+
with:
66+
fetch-depth: 0
6567
- name: Set up minio
6668
run: |
6769
docker run -d -p 9000:9000 --name minio \
@@ -79,6 +81,17 @@ jobs:
7981
run: |
8082
# Use uv to install dependencies directly from requirements files
8183
uv pip sync requirements.txt requirements-dev.txt
84+
- name: Lint new migrations for unsafe operations
85+
if: github.event_name == 'pull_request'
86+
env:
87+
BASE_REF: ${{ github.base_ref }}
88+
DJANGO_SETTINGS_MODULE: contentcuration.not_production_settings
89+
run: |
90+
set -euo pipefail
91+
git fetch --no-tags origin "$BASE_REF"
92+
base="$(git merge-base "origin/$BASE_REF" HEAD)"
93+
test -n "$base"
94+
python contentcuration/manage.py lintmigrations --git-commit-id "$base" --no-cache --warnings-as-errors
8295
- name: Test pytest
8396
run: |
8497
sh -c './contentcuration/manage.py makemigrations --check'

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ migrate:
3939
# 4) Remove the management command from this `deploy-migrate` recipe
4040
# 5) Repeat!
4141
deploy-migrate:
42-
echo "Nothing to do here!"
42+
# studio#5974: remove at cutover.
43+
python contentcuration/manage.py backfill_column --model contentcuration.File --source-field file_size --target-field file_size_bigint
4344

4445
contentnodegc:
4546
python contentcuration/manage.py garbage_collect
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import hashlib
2+
3+
import pgtrigger
4+
5+
6+
def mirror_field(source, target):
7+
"""Mirror Django field `source` into `target` via a BEFORE INSERT/UPDATE
8+
trigger (expand/contract dual-write)."""
9+
10+
def decorator(model):
11+
source_col = model._meta.get_field(source).column
12+
target_col = model._meta.get_field(target).column
13+
name = "mirror_{}_to_{}".format(source_col, target_col)
14+
if len(name) > 43: # stay safely under pgtrigger's trigger-name limit
15+
digest = hashlib.sha1(
16+
"{}_{}".format(source_col, target_col).encode()
17+
).hexdigest()[:8]
18+
name = "mirror_{}".format(digest)
19+
# Change-guard (IS DISTINCT FROM): keeps a read cutover from clobbering
20+
# writes to the repointed column with the stale source value.
21+
trigger = pgtrigger.Trigger(
22+
name=name,
23+
when=pgtrigger.Before,
24+
operation=pgtrigger.Insert | pgtrigger.Update,
25+
func="IF NEW.{s} IS DISTINCT FROM OLD.{s} THEN NEW.{t} = NEW.{s}; END IF; RETURN NEW;".format(
26+
s=source_col, t=target_col
27+
),
28+
)
29+
return pgtrigger.register(trigger)(model)
30+
31+
return decorator
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from django.apps import apps
2+
from django.core.exceptions import FieldDoesNotExist
3+
from django.core.management.base import BaseCommand
4+
from django.core.management.base import CommandError
5+
from django.db import transaction
6+
from django.db.models import F
7+
8+
9+
class Command(BaseCommand):
10+
help = (
11+
"Idempotent, resumable online backfill of one column into another, in batches."
12+
)
13+
14+
def add_arguments(self, parser):
15+
parser.add_argument("--model", required=True, help="app_label.ModelName")
16+
parser.add_argument("--source-field", required=True)
17+
parser.add_argument("--target-field", required=True)
18+
parser.add_argument("--batch-size", type=int, default=10000)
19+
parser.add_argument("--start-id", default=None, help="resume from this pk")
20+
parser.add_argument(
21+
"--progress-check",
22+
action="store_true",
23+
help="report unbackfilled rows, exit nonzero if any",
24+
)
25+
26+
def _resolve_model_fields(self, model_label, source, target):
27+
try:
28+
model = apps.get_model(model_label)
29+
except (LookupError, ValueError) as e:
30+
raise CommandError("Bad --model {!r}: {}".format(model_label, e))
31+
try:
32+
model._meta.get_field(source)
33+
model._meta.get_field(target)
34+
except FieldDoesNotExist as e:
35+
raise CommandError(str(e))
36+
return model
37+
38+
def _batch_end_pk(self, queryset, pk_name, start_pk, batch_size):
39+
"""Last pk of the batch of `batch_size` rows starting at `start_pk`.
40+
41+
Returns None when fewer than `batch_size` rows remain at/after
42+
`start_pk` — the final, short batch. Keyset paging by pk, so it works
43+
for any pk type (int or UUID).
44+
"""
45+
return (
46+
queryset.filter(pk__gte=start_pk)
47+
.order_by(pk_name)
48+
.values_list("pk", flat=True)[batch_size - 1 : batch_size]
49+
.first()
50+
)
51+
52+
def handle(self, *args, **options):
53+
if options["batch_size"] < 1:
54+
raise CommandError("--batch-size must be >= 1")
55+
source = options["source_field"]
56+
target = options["target_field"]
57+
model = self._resolve_model_fields(options["model"], source, target)
58+
59+
pk_name = model._meta.pk.name
60+
batch_size = options["batch_size"]
61+
only_unfilled = {target + "__isnull": True, source + "__isnull": False}
62+
unfilled = model.objects.filter(**only_unfilled)
63+
unfilled_pks = unfilled.order_by(pk_name).values_list("pk", flat=True)
64+
65+
if options["progress_check"]:
66+
# exists(), not count() — the target table can have millions of rows.
67+
if unfilled.exists():
68+
raise CommandError("backfill incomplete: rows still pending")
69+
self.stdout.write("Backfill complete: no rows pending.")
70+
return
71+
72+
# Start at the first unfilled pk (>= --start-id if given); re-runs and
73+
# resumes skip straight past an already-filled prefix.
74+
batch_start = unfilled_pks
75+
if options["start_id"] is not None:
76+
batch_start = batch_start.filter(pk__gte=options["start_id"])
77+
batch_start = batch_start.first()
78+
79+
total = 0
80+
while batch_start is not None:
81+
batch_end = self._batch_end_pk(
82+
model.objects, pk_name, batch_start, batch_size
83+
)
84+
if batch_end is None:
85+
window = {"pk__gte": batch_start}
86+
else:
87+
window = {"pk__gte": batch_start, "pk__lte": batch_end}
88+
with transaction.atomic():
89+
total += model.objects.filter(**window, **only_unfilled).update(
90+
**{target: F(source)}
91+
)
92+
self.stdout.write(
93+
"backfilled through pk={} (updated {} so far)".format(
94+
batch_start if batch_end is None else batch_end, total
95+
)
96+
)
97+
if batch_end is None:
98+
break
99+
batch_start = unfilled_pks.filter(pk__gt=batch_end).first()
100+
self.stdout.write("Done. {} rows updated.".format(total))
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 3.2.24 on 2026-06-23 05:56
2+
import pgtrigger.compiler
3+
import pgtrigger.migrations
4+
from django.db import migrations
5+
from django.db import models
6+
from django.db.models import Q
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("contentcuration", "0166_add_usersubscription"),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="file",
18+
name="file_size_bigint",
19+
field=models.BigIntegerField(blank=True, null=True),
20+
),
21+
migrations.AddIndex(
22+
model_name="file",
23+
index=models.Index(
24+
fields=["checksum", "file_size_bigint"],
25+
name="file_checksum_fsizebig_idx",
26+
condition=Q(file_size_bigint__isnull=False),
27+
),
28+
),
29+
pgtrigger.migrations.AddTrigger(
30+
model_name="file",
31+
trigger=pgtrigger.compiler.Trigger(
32+
name="mirror_file_size_to_file_size_bigint",
33+
sql=pgtrigger.compiler.UpsertTriggerSql(
34+
func="IF NEW.file_size IS DISTINCT FROM OLD.file_size THEN NEW.file_size_bigint = NEW.file_size; END IF; RETURN NEW;",
35+
hash="051e321c4cdf91ea81f96b9f9a29e3b5015def67",
36+
operation="INSERT OR UPDATE",
37+
pgid="pgtrigger_mirror_file_size_to_file_size_bigint_54326",
38+
table="contentcuration_file",
39+
when="BEFORE",
40+
),
41+
),
42+
),
43+
]

contentcuration/contentcuration/models.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
from contentcuration.constants import feedback
8080
from contentcuration.constants import user_history
8181
from contentcuration.constants.contentnode import kind_activity_map
82+
from contentcuration.db.dual_write import mirror_field
8283
from contentcuration.db.models.expressions import Array
8384
from contentcuration.db.models.functions import ArrayRemove
8485
from contentcuration.db.models.functions import Unnest
@@ -3255,6 +3256,8 @@ class StagedFile(models.Model):
32553256

32563257

32573258
FILE_DISTINCT_INDEX_NAME = "file_checksum_file_size_idx"
3259+
# studio#5974: bigint shadow of FILE_DISTINCT_INDEX_NAME, for the file_size widening.
3260+
FILE_DISTINCT_BIGINT_INDEX_NAME = "file_checksum_fsizebig_idx"
32583261
FILE_MODIFIED_DESC_INDEX_NAME = "file_modified_desc_idx"
32593262
FILE_DURATION_CONSTRAINT = "file_media_duration_int"
32603263
MEDIA_PRESETS = [
@@ -3266,6 +3269,14 @@ class StagedFile(models.Model):
32663269
]
32673270

32683271

3272+
# studio#5974 swap (next release, after backfill completes). One migration:
3273+
# - drop the @mirror_field decorator and the file_size_bigint field below
3274+
# - file_size = models.BigIntegerField(blank=True, null=True)
3275+
# - DB ops: drop the trigger + int file_size column, then RENAME file_size_bigint -> file_size
3276+
# - wrap in SeparateDatabaseAndState so the int->bigint AlterField is state-only (no rewrite)
3277+
# Transparent to old pods: they keep writing file_size (now bigint); only a brief metadata lock.
3278+
# Do NOT add db_column to reach file_size_bigint first — that generation breaks at the rename.
3279+
@mirror_field("file_size", "file_size_bigint") # studio#5974: dual-write int->bigint
32693280
class File(models.Model):
32703281
"""
32713282
The bottom layer of the contentDB schema, defines the basic building brick for content.
@@ -3275,6 +3286,9 @@ class File(models.Model):
32753286
id = UUIDField(primary_key=True, default=uuid.uuid4)
32763287
checksum = models.CharField(max_length=400, blank=True, db_index=True)
32773288
file_size = models.IntegerField(blank=True, null=True)
3289+
file_size_bigint = models.BigIntegerField(
3290+
blank=True, null=True
3291+
) # studio#5974 shadow
32783292
file_on_disk = models.FileField(
32793293
upload_to=object_storage_name,
32803294
storage=default_storage,
@@ -3485,6 +3499,11 @@ class Meta:
34853499
models.Index(
34863500
fields=["checksum", "file_size"], name=FILE_DISTINCT_INDEX_NAME
34873501
),
3502+
models.Index(
3503+
fields=["checksum", "file_size_bigint"],
3504+
name=FILE_DISTINCT_BIGINT_INDEX_NAME,
3505+
condition=Q(file_size_bigint__isnull=False),
3506+
),
34883507
models.Index(fields=["-modified"], name=FILE_MODIFIED_DESC_INDEX_NAME),
34893508
]
34903509
constraints = [

contentcuration/contentcuration/not_production_settings.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,14 @@
2020

2121
AWS_AUTO_CREATE_BUCKET = True
2222

23+
INSTALLED_APPS += ("django_migration_linter",) # noqa F405
24+
25+
MIGRATION_LINTER_OPTIONS = {
26+
"exclude_apps": [
27+
"kolibri_content"
28+
], # SQLite content-export app; not on the safe-DDL Postgres backend
29+
"sql_analyser": "postgresql",
30+
}
31+
2332
# Use local instance for curriculum automation for development
2433
CURRICULUM_AUTOMATION_API_URL = "http://localhost:8000"

contentcuration/contentcuration/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"django_celery_results",
9393
"kolibri_public",
9494
"automation",
95+
"pgtrigger",
9596
)
9697

9798
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"

0 commit comments

Comments
 (0)