Aller au contenu

Migrations — Flyway, forward-only, index manuels

La décision

Le schéma PostgreSQL est versionné en SQL pur avec Flyway (migration/flyway/V<YYYYMMDDHHMM>__description.sql), en forward-only : pas de down-migration. Les gros index sur tables chargées se créent à la main (CREATE INDEX CONCURRENTLY), hors Flyway.

Le contexte

Flyway a été choisi par le passé : il correspondait aux contraintes, il marche bien et il est fiable — les raisons précises du choix initial se sont perdues, mais rien ne motive d'en changer. L'historique pré-Flyway (199 fichiers numérotés) sert de baseline dans migration/legacy-before-flyway/. TypeORM est à synchronize: false et ne touche jamais au schéma ; les outils de migration Kysely ne sont pas utilisés — la migration est l'affaire du SQL, pas du runtime applicatif.

Pourquoi forward-only

Pas de down-migrations : ça simplifie énormément le process, et un rollback de schéma est de toute façon très pénible — et rarement testé honnêtement. En cas de problème, on corrige en avant avec une nouvelle migration. Flyway n'est invoqué qu'avec info / migrate / validate (flyway.sh).

Le runbook des index manuels

Le problème. CREATE INDEX bloque les écritures pendant la construction ; CREATE INDEX CONCURRENTLY ne bloque pas, mais PostgreSQL l'interdit dans une transaction — et Flyway exécute chaque migration en transaction. Sur les tables volumineuses (souvent les tables invest-facing : customers ~700k rows, wallet_transactions, bricks…), l'index passe donc hors Flyway.

Le process.

  1. Écrire le script dans migration/manual/ : CREATE INDEX CONCURRENTLY IF NOT EXISTS …, suivi d'un ANALYZE <table>. Le fichier documente le contexte : requêtes ciblées et métriques avant/après — ex. portfolio-home-metrics-indexes-concurrent.sql (30 590 ms → 246 ms).
  2. L'exécuter manuellement en production avant le déploiement du code qui s'appuie dessus.
  3. IF NOT EXISTS rend le script idempotent : un environnement reconstruit peut le rejouer sans douleur.

Tables partitionnées. CREATE INDEX CONCURRENTLY ne s'applique pas à la table parente — il faut créer l'index sur chaque partition séparément. Exemple : bricks est partitionnée en bricks_not_primary_available, bricks_primary_available, etc. Le script portfolio-home-metrics-indexes-concurrent.sql montre le pattern (un CREATE INDEX CONCURRENTLY par partition, puis ANALYZE sur la table parente).

Enfiler un job au déploiement

Le problème. Un changement de schéma peut laisser des données vides ou obsolètes jusqu'au prochain cron planifié (souvent la nuit). Exemple : nouvelles colonnes sur leaderboard_investor_live à 0 jusqu'au refresh de 3h UTC.

La solution. Une migration Flyway peut enfiler un run unique via graphile_worker.add_job, avec un run_at proche du déploiement :

DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'graphile_worker') THEN
    PERFORM graphile_worker.add_job(
      identifier := 'refresh-leaderboard',
      payload := '{}'::json,
      queue_name := 'refresh-leaderboard-queue',
      run_at := now() + interval '5 minutes',
      job_key := 'refresh-leaderboard-initial-deploy'
    );
  END IF;
END $$;

Les invariants.

Point Pourquoi
Garde graphile_worker Le schéma n'existe pas sur une DB fraîche (CI, nouvel env) — le cron planifié rattrapera
run_at décalé (5–15 min) Laisse le worker finir son rollout et enregistrer la task avant exécution
job_key unique Déduplication — un seul run par déploiement
Migration après le DDL concerné La table/colonnes doivent exister avant que le job ne tourne

Quand l'utiliser : backfill déclenché par un changement de schéma, dépendant d'un worker déjà déployé — pas pour remplacer un cron récurrent.

Quand ne pas l'utiliser : logique métier complexe (→ service ou cron dédié) ; sur un env sans worker, la garde sur le schéma suffit — le cron planifié rattrapera.

Exemples en prod :

sequenceDiagram
  participant CI as Deploy_Prod
  participant Flyway
  participant DB as PostgreSQL
  participant Worker as Graphile_Worker

  CI->>Flyway: migrate
  Flyway->>DB: DDL + add_job run_at=now+5min
  CI->>Worker: rollout nouvelle version
  Worker->>DB: execute job refresh-leaderboard

Où et quand Flyway s'exécute

Contexte Mécanisme
Production api-deployToProd.yml : check-flyway-migrations (info) → db-migration-flyway (migrate, seulement si pending) → validate-migrations. Le check fail si Flyway marque une versioned migration Ignored (out-of-order) — sinon migrate/validate sont skippés et le code part sans le schéma
Dev api-deployToDev.yml : migrate toujours — échoue déjà sur out-of-order (Detected resolved migration not applied)
Tests d'intégration Service Flyway du docker-compose : snapshot baseline + migrations delta (guide)
Dev local Branches Neon personnelles via Doppler — pas de Postgres local

Out-of-order. Un timestamp de migration plus ancien que le max déjà appliqué en DB → Flyway ne l'applique pas. Fix : renommer le fichier avec une version strictement après le max de __flyway_schema_history__ (contenu inchangé), uniquement si l'ancienne version n'a jamais été appliquée. Jamais outOfOrder=true en CI permanente.

Migrate before traffic. En prod, Flyway tourne avant le deploy du code : le nouvel app ne sert jamais de traffic contre un schéma pré-migration. Conséquence : pas de dual-read / dual-write « pour la race de deploy » sur un rename ou une migration JSON — le code peut assumer que la migration a déjà run.

Les types suivent à la main

Une migration qui touche une table de la stack Kysely implique, dans la même PR : le SQL Flyway, le schema zod (__new/lib/kysely/schemas/) et le type Database. Pas de codegen (pourquoi) — la discipline est le mécanisme de sync, et ky_parse* attrape le drift au runtime.