Files
holy-flavor-rater/MIGRATION.md
T
2026-06-09 15:07:04 +02:00

192 lines
6.0 KiB
Markdown

# Django → Rust migration runbook (seamless, reversible)
Migrate the running Django backend to the Rust backend (`backend-rs/`) with
**zero downtime** and a **per-step rollback**. Endpoint groups cut over one at a
time behind nginx; either backend can serve any request, so rollback is just the
reverse edit and data never forks.
There is a driver script that performs every step below:
`scripts/cutover.sh` (run `scripts/cutover.sh` with no args for the menu). Do the
steps manually or let the script do them — both edit the same files.
---
## Why it's safe
Both backends share one substrate, so a request is serviceable by either:
| Shared thing | How |
|---|---|
| Database | same `backend/db.sqlite3` (WAL on), bind-mounted into both containers |
| Media | same `backend/media/`; nginx serves `/media/` straight from the mount (`:ro`) |
| Auth | same `SECRET_KEY` → SimpleJWT HS256 tokens verify on both; cookies cross-valid |
| Token blacklist | shared `token_blacklist_*` tables → logout/refresh consistent across a flip |
**The one invariant — never violate:** exactly **one** scheduler may own the
`api_job` table at a time. Django's Celery `beat` owns it until Phase 3; the Rust
in-process scheduler (`ENABLE_SCHEDULER`) stays **false** until then. Never both,
or scheduled jobs fire twice.
---
## Prerequisites
1. **`SECRET_KEY` in `.env` must equal Django's exact value.** If it differs, all
existing JWTs break the instant you cut over auth. Verify before starting.
2. Back up first: `bash scripts/backup.sh` (snapshots SQLite + media).
3. Optionally enable **live nginx reloads** so flips don't rebuild the frontend
image. Add to the `frontend` service in `docker-compose.yml`:
```yaml
volumes:
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf
- ./frontend/proxy_params.conf:/etc/nginx/proxy_params.conf
```
then `docker compose up -d frontend`. Without this, each phase rebuilds the
frontend image (`docker compose up -d --build frontend`) — slower, but still
no API downtime. (`scripts/cutover.sh dev-mount` prints these lines.)
---
## Phase 0 — Deploy Rust in shadow (no traffic)
Bring up `holy-rust` beside Django. It only reads; nginx still sends all traffic
to Django.
```bash
docker compose up -d --build backend-rs # or: scripts/cutover.sh phase0
```
Smoke-test it directly, bypassing nginx:
```bash
docker exec holy-frontend wget -qO- http://holy-rust:8001/health/ # or: scripts/cutover.sh smoke
```
Spot-check a few endpoints against `holy-rust:8001` and compare to Django.
**Rollback:** `docker compose stop backend-rs`.
---
## Phase 1 — Reads
Lowest risk: read-only, no auth side effects.
Flip these locations in `frontend/nginx.conf` from `django_backend` →
`rust_backend`, then reload:
- `/api/categories/`
- `/api/flavors/`
- `/api/banners/`
```bash
scripts/cutover.sh phase1
```
Watch `docker logs holy-rust` and your error rate for a few minutes.
**Rollback:** `scripts/cutover.sh rollback 1`.
---
## Phase 2 — Auth + writes
JWTs and cookies are cross-valid, so logged-in sessions survive the flip.
Flip:
- `/api/auth/`
- `= /api/token/` (legacy JWT alias)
- `/api/ratings/`
- `/api/replies/`
- `/api/users/`
- `/api/notifications/`
- `/api/tickets/`
```bash
scripts/cutover.sh phase2
```
**Rollback:** `scripts/cutover.sh rollback 2`.
---
## Phase 3 — Admin + jobs (the careful one)
This is the only step that touches the scheduler invariant. Do it in order.
1. Flip `/api/admin-custom/` → `rust_backend`, reload.
2. **Stop Django's scheduler/workers** so nobody schedules:
```bash
docker compose stop beat worker flower
```
3. **Hand scheduling to Rust:** set `RUST_ENABLE_SCHEDULER=true` in `.env`, then
recreate the Rust container:
```bash
docker compose up -d backend-rs
```
4. Confirm single ownership:
```bash
docker logs holy-rust | grep "job scheduler ENABLED"
```
```bash
scripts/cutover.sh phase3 # does 1-4, prompts before the handover
```
At no point are both schedulers on → no double-run.
**Rollback:** `scripts/cutover.sh rollback 3` — sets `RUST_ENABLE_SCHEDULER=false`,
recreates `backend-rs`, restarts `beat worker flower`, flips admin back to Django.
---
## Phase 4 — Retire Django
Point the catch-all at Rust. Rust also serves OpenAPI + Swagger, so `/api/schema`
goes with it.
1. Flip `location /api/` and `/api/schema` → `rust_backend`, reload:
```bash
scripts/cutover.sh phase4
```
Django now receives only `/admin/` + `/static/` (its own admin site). `/media/`
is already served straight from nginx, independent of either backend.
2. Decide the Django admin site's fate. The app's admin UI uses
`/api/admin-custom/` (now on Rust), so Django `/admin/` is usually unneeded:
- **Keep it:** leave `backend` up for `/admin/` + `/static/`.
- **Drop it:** `docker compose stop backend`, then delete the `/admin/` and
`/static/` location blocks from `nginx.conf` and reload.
3. Once Django is fully off, stop Redis (only a Celery broker + Django cache —
Rust uses neither):
```bash
docker compose stop redis
```
4. Eventually remove `backend`, `worker`, `beat`, `flower`, `redis` from
`docker-compose.yml`.
**Rollback:** `scripts/cutover.sh rollback 4` (before removing services).
---
## Verify anytime
```bash
scripts/cutover.sh status # current routing per group + scheduler ownership
```
---
## Notes / caveats
- **Rate-limit counters reset at cutover.** Django's `django_ratelimit` lives in
Redis; Rust's throttle is in-process. Acceptable — windows are minutes/hours.
- **Reloads:** if the conf is baked into the image (default), a flip rebuilds the
frontend image. Bind-mount the conf (see Prerequisites) for instant
`nginx -s reload`. The script auto-detects which mode you're in.
- **Canonical data:** `backend/db.sqlite3` + `backend/media/` are host bind
mounts — never recreated by rebuilds. Keep them; they're the source of truth
the whole time.
- The same content lives in `backend-rs/README.md` ("Production cutover runbook")
as the in-tree reference.