|
| 1 | +# Almost zero-downtime migrations — expand/contract runbook |
| 2 | + |
| 3 | +On large tables (e.g. `File` has ~100 M rows) a single migration can cause downtime in two ways: |
| 4 | +- by taking an `ACCESS EXCLUSIVE` lock / rewriting the table |
| 5 | +- by shipping a schema the still-running old pods can't use (a dropped or renamed column). |
| 6 | + |
| 7 | +The expand/contract procedure below avoids both. Its one residual cost is the brief metadata-only lock taken for the drop + rename migration, hence "almost." |
| 8 | + |
| 9 | +## Linting (already configured) |
| 10 | + |
| 11 | +- `django-migration-linter` - flags backward-incompatible schema (drops, renames, NOT NULL adds) old pods would break on. |
| 12 | + |
| 13 | +## Procedure |
| 14 | + |
| 15 | +Goal: widen `File.file_size` from int to bigint with no table rewrite and no backward-incompatible window. The app-visible column stays named `file_size` throughout. Only its underlying storage swaps — from the int column to a pre-backfilled bigint column. Because the name is preserved, old pods keep writing to `file_size` (now bigint) without error. |
| 16 | + |
| 17 | +### Release 1 — expand |
| 18 | + |
| 19 | +Add the shadow field and the dual-write trigger: |
| 20 | + |
| 21 | +```python |
| 22 | +from contentcuration.db.dual_write import mirror_field |
| 23 | + |
| 24 | +@mirror_field("file_size", "file_size_bigint") |
| 25 | +class File(models.Model): |
| 26 | + file_size = models.IntegerField(blank=True, null=True) |
| 27 | + file_size_bigint = models.BigIntegerField(blank=True, null=True) |
| 28 | +``` |
| 29 | + |
| 30 | +`makemigrations` emits a nullable `AddField` and the `CreateTrigger` — both safe (no rewrite, no lock). New writes now land in both columns. |
| 31 | + |
| 32 | +Backfill old rows in the same release: wire `backfill_column` as a `deploy-migrate` step in the Makefile, which runs after `migrate`, so the column and trigger already exist: |
| 33 | + |
| 34 | +```bash |
| 35 | +python contentcuration/manage.py backfill_column \ |
| 36 | + --model contentcuration.File --source-field file_size --target-field file_size_bigint |
| 37 | +``` |
| 38 | + |
| 39 | +Can also run the above command with `--progress-check` as a read only to see if any backfills are still required. |
| 40 | + |
| 41 | +### Release 2 — swap (cutover + rename) |
| 42 | + |
| 43 | +After backfill completes, swap the storage in a single migration. Drop the shadow field and decorator; `file_size` is now bigint: |
| 44 | + |
| 45 | +```python |
| 46 | +class File(models.Model): |
| 47 | + file_size = models.BigIntegerField(blank=True, null=True) |
| 48 | +``` |
| 49 | + |
| 50 | +The migration drops the trigger and the int column, then renames the bigint column onto `file_size`: |
| 51 | + |
| 52 | +```python |
| 53 | +operations = [ |
| 54 | + IgnoreMigration(), # safe: net change is an int->bigint widening; see note below |
| 55 | + migrations.SeparateDatabaseAndState( |
| 56 | + state_operations=[ |
| 57 | + migrations.RemoveField("file", "file_size_bigint"), |
| 58 | + migrations.AlterField( |
| 59 | + "file", "file_size", models.BigIntegerField(blank=True, null=True) |
| 60 | + ), |
| 61 | + pgtrigger.migrations.RemoveTrigger( |
| 62 | + "file", "mirror_file_size_to_file_size_bigint" |
| 63 | + ), |
| 64 | + ], |
| 65 | + database_operations=[ |
| 66 | + migrations.RunSQL( |
| 67 | + sql=( |
| 68 | + "DROP TRIGGER IF EXISTS pgtrigger_mirror_file_size_to_file_size_bigint_54326" |
| 69 | + " ON contentcuration_file;" |
| 70 | + 'ALTER TABLE contentcuration_file DROP COLUMN "file_size";' |
| 71 | + 'ALTER TABLE contentcuration_file RENAME COLUMN "file_size_bigint" TO "file_size";' |
| 72 | + ), |
| 73 | + reverse_sql=( |
| 74 | + 'ALTER TABLE contentcuration_file RENAME COLUMN "file_size" TO "file_size_bigint";' |
| 75 | + 'ALTER TABLE contentcuration_file ADD COLUMN "file_size" integer;' |
| 76 | + ), |
| 77 | + ), |
| 78 | + ], |
| 79 | + ), |
| 80 | +] |
| 81 | +``` |
| 82 | + |
| 83 | +`SeparateDatabaseAndState` allows us to let Django know what has been migrated, while doing specific raw SQL operations to get the exact data preserving sequence of events that we want. Copy the trigger `pgid` from release 1's `AddTrigger`. |
| 84 | + |
| 85 | +Why the swap is transparent to old pods: |
| 86 | + |
| 87 | +- Their queries reference `file_size` by name; the swap preserves that name, so they keep working — their int writes fit the bigint column. |
| 88 | +- The net app-visible change is an `int → bigint` widening, which is backward-compatible. |
| 89 | +- The only disruption is the brief metadata-only lock while the DDL runs; `DROP COLUMN` / `RENAME COLUMN` don't rewrite the table. |
| 90 | + |
| 91 | +The linter flags the drop and rename as backward-incompatible; `IgnoreMigration()` acknowledges the sequencing makes them safe. |
| 92 | + |
| 93 | +**Don't cut over to the physical name first.** Aliasing the ORM field to `file_size_bigint` via `db_column` creates a pod generation that queries `file_size_bigint` by name. The later rename then breaks that generation for the whole rollover, and adds a release. Preserving `file_size` is what makes the rename free. |
| 94 | + |
| 95 | +## Tooling |
| 96 | + |
| 97 | +- **`@mirror_field(source, target)`** in `contentcuration/db/dual_write.py` — BEFORE INSERT/UPDATE trigger copying field `source` → `target`. Change-guarded: an unconditional copy corrupts data at swap. |
| 98 | +- **`backfill_column`** — idempotent, resumable (`--start-id <pk>`), batched (`--batch-size`); one transaction per batch. `--progress-check` tests for remaining rows without writing and exits nonzero if any remain. |
| 99 | +- **`lintmigrations`** — run locally before pushing: |
| 100 | + ```bash |
| 101 | + python contentcuration/manage.py lintmigrations --git-commit-id <base-ref> --no-cache --warnings-as-errors |
| 102 | + ``` |
| 103 | + `--git-commit-id` is a flag, not positional — a positional value is read as an app label and lints nothing. |
| 104 | +- **`IgnoreMigration()`** — escape hatch for a migration whose backward-incompatibility is made safe by release sequencing. |
0 commit comments