Skip to content

Commit 659cacc

Browse files
rtibblesclaude
andcommitted
docs(migrations): add expand/contract zero-downtime runbook
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LfZvkigk8hdsKdEif3hzBi
1 parent 101e200 commit 659cacc

2 files changed

Lines changed: 108 additions & 0 deletions

File tree

docs/_index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717

1818
- [Docker + Kubernetes Studio Instance Setup](./docker_kubernetes_setup.md)
1919

20+
## Database
21+
22+
- [Zero-downtime migrations (expand/contract runbook)](./zero_downtime_migrations.md)
23+
2024
## API
2125

2226
- [API Endpoints](./api_endpoints.md)

docs/zero_downtime_migrations.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
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

Comments
 (0)