Tous les articles

Reseed de données sans downtime via une commande maison

26 juin 20268 min de lecture
Sur Wakfuli, les données de jeu (effets, sorts, états, sublimations, objets cosmétiques) ne vivent pas dans le code de l'application : elles sont publiées à part, en release, et rechargées périodiquement dans la base de prod quand une nouvelle version du jeu sort. Le problème, c'est que "recharger des données de jeu dans une base de prod" est exactement le genre de phrase qui, mal exécutée, se termine par une base vide un samedi soir.

Un import qui plante à moitié, un format de dump qui a légèrement changé, une table renommée entre deux versions : n'importe lequel de ces trois cas peut transformer un reseed de routine en incident. J'ai fini par écrire une commande ace dédiée (game:seed, sous AdonisJS) dont le seul travail est de rendre cette opération ennuyeuse. Pas de script bash oublié dans un coin, pas de suite de commandes psql copiées depuis un README : une commande versionnée, avec son propre filet de sécurité intégré.

La règle : ne jamais toucher la prod avant d'avoir validé ailleurs

Le principe de base tient en une phrase : avant de restaurer quoi que ce soit dans la vraie base, on restaure exactement la même chose dans une base de staging jetable, et on vérifie que ça a produit quelque chose de sensé. Si la validation échoue, on abandonne avant d'avoir touché à la prod. La prod n'est modifiée que dans la dernière étape, une fois qu'on a la preuve que le dump est exploitable.

  1. 1

    Télécharger

    Récupère le dump .sql.gz associé à la release ciblée, puis le décompresse.
  2. 2

    Sauvegarder

    Sauvegarde complète de la base courante, avant tout autre changement.
  3. 3

    Restaurer en staging

    Crée une base de staging jetable et y restaure le dump.
  4. 4

    Valider

    Compte les lignes en staging pour vérifier qu'il n'est pas vide.
  5. 5

    Appliquer en prod

    Seulement si la validation a réussi : restauration dans la vraie base.
  6. 6

    Nettoyer

    Drop du staging, puis vérification finale.
game_seed.ts
ts
// ── 8. Validate staging ───────────────────────────────────────────────────
const stagingEffects = await this.verifyRowCounts(dbHost, dbPort, gameUser, gamePass, stagingDb);
if (stagingEffects === 0) {
  this.logger.error(`Staging validation failed (0 effects). Backup available at: ${backupPath}`);
  this.exitCode = 1;
  return;
}
this.logger.success('Staging OK');
 
// ── 9. Restore to main DB ─────────────────────────────────────────────────
this.logger.info(`Applying ${tag} to ${gameDb}...`);
await this.restoreSql(dbHost, dbPort, superUser, superPass, gameDb, cleanSql); 

Le staging n'est pas une base à demeure : elle est créée juste avant l'essai et droppée juste après, que la restauration principale se soit bien passée ou non. Ça coûte quelques dizaines de secondes de plus à chaque seed, mais ça déplace le pire scénario possible (un dump corrompu ou vide) d'un incident en prod vers un simple message d'erreur dans les logs de la commande.

La sauvegarde, elle, ne dépend pas de pg_dump. Comme la commande tourne déjà dans le contexte de l'application, avec l'ORM connecté, j'ai écrit un petit export maison : lister les tables du schéma public, puis sérialiser chaque ligne en INSERT, en échappant les guillemets simples et en distinguant les types (nombres, booléens, dates, JSON). Le résultat est gzippé au fil de l'eau plutôt que construit en mémoire, pour ne pas faire exploser la RAM sur les grosses tables. Ce n'est pas aussi complet qu'un vrai pg_dump, mais ça tourne partout où l'app tourne, sans dépendance système à installer.

Le piège du split SQL naïf

Le détail qui m'a vraiment mordu, c'est la façon de découper le dump en instructions individuelles avant de les rejouer. Le réflexe naturel, c'est de faire sql.split(';') et d'exécuter chaque morceau. Ça marche sur un dump de test, et ça casse sur le vrai dump, silencieusement, sur une table précise.

La raison : certaines valeurs textuelles stockées en base sont elles-mêmes des scripts, des chaînes de critère de jeu du genre "...alors (2);". Le dump les écrit comme littéral SQL entre guillemets simples, point-virgule inclus à l'intérieur de la chaîne. Un split naïf sur ; coupe en plein milieu de cette chaîne, et l'INSERT qui suit se retrouve amputé de sa deuxième moitié.

Le piège

Le pire, c'est que ça ne plante pas forcément tout de suite : parfois le fragment restant reste syntaxiquement valide tout seul, et l'erreur ne se voit qu'au comptage de lignes, bien plus tard.

La parade, c'est un splitter qui suit l'état "je suis dans une chaîne ou pas" caractère par caractère, et qui ignore les point-virgules rencontrés à l'intérieur d'une chaîne :

game_seed.ts
ts
private splitStatements(sql: string): string[] {
  const statements: string[] = [];
  let current = '';
  let inString = false;
 
  for (let i = 0; i < sql.length; i++) {
    const ch = sql[i];
 
    if (inString) {
      current += ch;
      if (ch === "'" && sql[i + 1] === "'") {
        // guillemet simple échappé ''
        current += sql[++i];
      } else if (ch === "'") {
        inString = false;
      }
    } else if (ch === "'") {
      inString = true;
      current += ch;
    } else if (ch === ';') { 
      const trimmed = current.trim();
      if (trimmed.length > 0) statements.push(trimmed);
      current = '';
    } else {
      current += ch;
    }
  }
 
  const trimmed = current.trim();
  if (trimmed.length > 0) statements.push(trimmed);
  return statements;
}

Le seul autre cas à gérer dans cette machine à états, c'est le guillemet simple doublé (''), la façon standard d'échapper un guillemet à l'intérieur d'une chaîne SQL. Sans ce cas, un guillemet échappé referme la chaîne trop tôt et l'état se dérègle pour tout le reste du fichier. Ce n'est ni élégant ni exotique comme technique, juste un parseur à deux états, mais c'est exactement le niveau de sophistication qu'il faut : pas besoin d'un vrai parseur SQL pour un dump connu, juste besoin de respecter la seule structure qui compte ici, les guillemets.

Tolérer la dérive de schéma plutôt que tout annuler

Deuxième gotcha, plus insidieux parce qu'il ne casse rien tout de suite : la liste des tables de jeu attendues et le contenu réel d'un dump donné peuvent diverger. Une table peut être ajoutée au code avant que la release de données qui la peuple existe encore, ou inversement, un vieux dump peut référencer une table qui n'existe plus dans le schéma courant. Cette dérive de schéma ne devrait pas, à elle seule, faire échouer tout le reseed.

Si la vérification post-restauration traitait ça comme une erreur fatale, un seul décalage de version suffirait à bloquer tout le reseed, y compris pour les dizaines d'autres tables parfaitement cohérentes. Le choix a été de vérifier l'existence de la table avant de la compter, et de la sauter avec un avertissement plutôt que d'interrompre la validation :

game_seed.ts
ts
const exists = await client.query(`SELECT to_regclass('public."${table}"') IS NOT NULL AS ok`);
if (!exists.rows[0].ok) { 
  this.logger.warning(`  ${table.padEnd(30)} (absent, ignorée)`);
  continue;
}
Table pivot (effets)
Absente ou vide : le seed s'arrête. C'est la seule vérification qui décide d'un abandon.
Autres tables de jeu
Absentes : un avertissement, la table est sautée, et le reseed continue.

La table pivot reste présente dans toutes les versions du schéma depuis le début. Le reste est informatif : utile pour repérer une dérive tôt, mais pas bloquant.

Trouver le "dernier" tag sans se faire avoir par le tri

Dernier piège, celui-là purement lié à l'historique du projet : la résolution automatique de "la dernière version disponible" ne peut pas se faire par un simple tri lexicographique des noms de tag.

Le piège

Le format de tag a changé en cours de route : un ancien format court, un nouveau format plus long avec plus de segments numériques. Un tri alphabétique classe l'un devant l'autre selon des règles qui n'ont rien à voir avec l'ordre chronologique réel des versions.

La solution la plus fiable a été de ne pas trier du tout : l'API de releases renvoie déjà les résultats en ordre ante-chronologique, donc il suffit de parcourir cette liste dans l'ordre et de prendre la première entrée qui correspond au filtre voulu (pas un brouillon, et le bon "flavor" de publication), plutôt que de recalculer un ordre à partir des chaînes de caractères.

Ce que j'en retiens

À retenir

Le point commun entre les trois pièges (split SQL, dérive de schéma, tri de tags), c'est qu'aucun des trois ne se voit sur un jeu de données de test propre. Ils n'apparaissent que sur le vrai dump, avec sa vraie diversité de contenu et son vrai historique de versions. La seule protection qui tient dans ce genre de situation, ce n'est pas d'anticiper chaque cas particulier à l'avance, c'est de construire le chemin de restauration de façon à ce qu'un cas non anticipé se traduise par un abandon propre en staging, avec un message qui pointe vers la sauvegarde, plutôt que par une prod à moitié écrasée. Le staging jetable n'est pas là pour éviter les bugs : il est là pour garantir qu'un bug non prévu coûte une commande relancée, pas une nuit de restauration en urgence.