Accéder au contenu principal

8min.

Accélérer votre CI : mettre en cache l’état de la base de données

Il y a quelques années, j’écrivais Accélérer votre intégration continue. L’article passait en revue tout un tas de techniques pour rendre une CI plus rapide : cache Composer, cache Yarn, layers Docker, parallélisation, tmpDir de PHPStan… et, tout en bas de la liste, une idée un peu à part : plutôt que de rejouer les fixtures à chaque build, charger un dump SQL pré-généré.

L’idée est séduisante, mais sous sa forme la plus simple (un dump statique, versionné, régénéré à la main) elle a un défaut rédhibitoire : il faut penser à le régénérer dès qu’une migration ou une fixture change. En pratique, un tel dump finit toujours par diverger de la réalité. Soit on oublie de le mettre à jour et les tests tournent sur des données périmées, soit on le régénère « au cas où » à chaque fois, et on perd tout le bénéfice.

J’ai eu l’occasion de mettre tout ça en place sur un projet client, une application Symfony dont la CI commençait à traîner en longueur. C’est ce contexte réel qui sert de fil rouge à cet article : nous allons voir comment transformer cette astuce en un vrai cache, automatique, adressé par son contenu, et qui s’invalide tout seul.

Petites précision avant de commencer :

  • Sur ce projet, toute l’automatisation (installation, fixtures, build, tests…) passe par des tâches Castor. Les extraits de code de cet article sont donc des tâches Castor, écrites en PHP, que la CI appelle comme n’importe quelle commande.
  • Nous utilisons des runners Github Action self-hosted sur un même serveur. Nous pouvons donc jouer avec le cache en partageant directement des dossiers entre les jobs. Mais il reste possible de faire la même chose avec les runners cloud fournis par Github en jouant avec les actions de cache natives.

Section intitulée le-cout-qu-on-veut-eviterLe coût qu’on veut éviter

Préparer la base de données de test de notre application n’est pas anodin. À chaque build, il faut :

  • Créer le schéma de la base principale en rejouant les migrations Doctrine (118 migrations, 542 requêtes SQL, ~10 s rien que pour ça) ;
  • Provisionner une seconde base, « géographique » : nos entités reproduisent le schéma d’un référentiel fourni par un prestataire tiers, mais on n’importe pas sa base complète, bien trop volumineuse. On fait donc un doctrine:schema:create, puis on charge nos propres fixtures pour cette base, dont un jeu de fichiers SQL d’environ 57 Mo ;
  • Charger les fixtures métier (avec Alice) ;
  • Générer les localisations à partir de ces données géo, puis recalculer les entités qui en dépendent ;
  • Réindexer le tout dans Elasticsearch.

Info

Une autre solution pour rendre nos migrations plus rapides serait de les fusionner. Nous en avions déjà parlé dans un précédent article.

Bout à bout, la seule construction de la base tourne autour de 50 secondes. Et comme chaque suite de tests tourne dans son propre job (PHPUnit, Behat, e2e…), on ne veut surtout pas payer ce coût plusieurs fois. Dans notre CI, un unique job prepare-application construit l’application et la base une fois, et tous les autres jobs en repartent.

Mais même une seule fois par build, c’est déjà trop. La grande majorité des pull requests ne touche ni aux migrations, ni aux fixtures, ni aux modèles. Reconstruire la base à l’identique à chaque push, c’est du gâchis.

Section intitulée l-idee-un-snapshot-adresse-par-son-contenuL’idée : un snapshot adressé par son contenu

Le raisonnement est simple. L’état final de la base est déterministe : à migrations, fixtures et code de chargement identiques, on obtient exactement la même base. Si on sait résumer « tout ce qui détermine la base » en une empreinte, alors on peut :

  1. calculer cette empreinte au début de la préparation ;
  2. si un dump correspondant existe déjà, le restaurer et s’arrêter là ;
  3. sinon, tout reconstruire comme avant, puis sauvegarder le dump sous cette empreinte pour la prochaine fois.

C’est le principe du cache adressé par le contenu (content-addressed), exactement comme Docker le fait avec ses layers. Mais toute la difficulté tient dans une seule question : comment savoir si le dump en cache est encore valable ?

Section intitulée la-cle-de-cache-le-coeur-du-systemeLa clé de cache, le cœur du système

C’est la partie la plus intéressante. La clé est un hash SHA-256 du contenu de tout ce qui influence les données. Voici la fonction qui la calcule :

function fixtures_snapshot_key(): string
{
    $root = PathHelper::getRoot();


    $hash = hash_init('sha256');
    hash_update($hash, SNAPSHOT_VERSION);       // bust manuel global
    hash_update($hash, date('Y-m-d'));          // bucket journalier

    // Les dossiers dont le contenu change le jeu de données
    $dirs = [
        $root . '/application/migrations',
        $root . '/application/fixtures',
        $root . '/application/src/Model',
        $root . '/application/src/Location',
        $root . '/application/src/Command/Debug',
        $root . '/application/src/Command/Location',
    ];

    foreach (finder()->files()->in($dirs)->sortByName() as $file) {
        hash_update($hash, substr($file->getPathname(), \strlen($root) + 1)); // le chemin…
        hash_update_file($hash, $file->getPathname()); // …et le contenu
    }

    // La config Doctrine et le lock des dépendances comptent aussi
    $extraFiles = finder()->files()->in($root . '/application/config/packages')->name('doctrine*.yaml')->sortByName();
    foreach ($extraFiles as $file) {
        hash_update($hash, substr($file->getPathname(), \strlen($root) + 1));
        hash_update_file($hash, $file->getPathname());
    }
    hash_update_file($hash, $root . '/application/composer.lock');

    return hash_final($hash);
}

Trois décisions méritent qu’on s’y arrête.

On hashe le contenu, pas un timestamp ni un hash de commit. C’est ce qui rend le cache correct. Le jour où quelqu’un ajoute une migration ou modifie une fixture, le contenu change, donc la clé change, donc on reconstruit, automatiquement, sans que personne n’ait à y penser. À l’inverse, une pull request qui ne touche qu’un template ou une feuille de style retombe sur la même clé et restaure le dump en quelques secondes. Cerise sur le gâteau : ça marche aussi avec des modifications non commitées, puisqu’on lit les fichiers directement sur le disque.

On inclut le chemin des fichiers dans le hash, pas seulement leur contenu. Sans ça, renommer ou déplacer un fichier sans en changer le contenu passerait inaperçu.

Un bucket journalier (date('Y-m-d')) entre dans la clé. Beaucoup de pages et de requêtes dépendent d’une notion de récence : « les articles publiés ces 7 derniers jours », « les annonces qui expirent bientôt »… Les fixtures qui les alimentent sont donc datées relativement à aujourd’hui. Si on figeait le même dump indéfiniment, ces dates vieilliraient : au bout de quelques jours, une entité « publiée il y a 2 jours » se retrouverait datée d’il y a une semaine, sortirait du périmètre testé, et casserait un test qui vérifie un affichage « récent ». En intégrant la date du jour dans la clé, on force au minimum une reconstruction quotidienne, ce qui garde ces données fraîches sans surcoût notable.

Info

Ce cache introduit un piège subtil sur les dates de fixtures. Comme le dump est généré une fois puis rejoué pendant un maximum de 24 h, le « maintenant » vu par les fixtures est celui de la génération, pas celui du test. Une fenêtre large ne pose aucun problème : une entité « publiée il y a 2 jours » le restera, à quelques heures près, pour tous les tests qui repartent du dump. Mais une fenêtre serrée devient un piège : une fixture « expire dans 1 heure » ou « créée il y a 5 minutes » aura déjà franchi son seuil au moment où un test restaure un dump vieux de trois heures. Pour ces cas-là, mieux vaut des marges larges, ou une donnée créée à la volée dans le test plutôt que dans les fixtures partagées.

Enfin, une constante SNAPSHOT_VERSION permet d’invalider tous les snapshots d’un coup, à la main, si on modifie la logique de génération elle-même. La ceinture et les bretelles.

Section intitulée restaurer-ou-reconstruireRestaurer ou reconstruire

Maintenant que la clé est calculée, le reste est mécanique. Le dump vit dans un dossier persistant, partagé entre les jobs et les builds successifs du runner (le même volume qui sert déjà de cache Composer/Yarn) :

$snapshotFile = "$HOME/fixtures-snapshots/{$key}.sql.gz";

if (file_exists($snapshotFile)) {
    // Hit : on restaure et on repart
    run("gunzip -c {$snapshotFile} | mariadb -h mysql -u root -p***");

    return;
}

// Miss : toute la reconstruction habituelle…
// …puis on sauvegarde pour la prochaine fois :
run("mariadb-dump --single-transaction --databases geo_fixtures app_fixtures \
     | gzip > {$snapshotFile}.tmp.$$ && mv {$snapshotFile}.tmp.$$ {$snapshotFile}");

Deux remarques sur ce bout de code.

Redis et Elasticsearch sont exclus du dump. Redis et Elasticsearch sont des datastores à part. J’ai préféré gardé leur fonctionnement actuel, c’est à dire que nous continuons à les recharger systématiquement, aussi bien sur un hit que sur un miss. Ce n’est pas gênant : ces deux étapes ne représentent que quelques secondes dans la CI. Le gros du temps à gagner était ailleurs.

L’écriture du dump est atomique, et ce détail-là fait toute la différence entre un cache qui marche et un cache qui vous cause plus de soucis qu’autre chose. On écrit dans un fichier temporaire unique (.tmp.$$, avec le PID du process), puis on le renomme (mv). Sur nos runners self-hosted partagés, deux pull requests peuvent très bien construire la même clé au même moment, ou un build peut être annulé en plein mariadb-dump. Sans cette précaution, un autre build restaurerait un dump tronqué et échouerait de façon aléatoire, le pire type de bug de CI. Le mv étant atomique sur un même système de fichiers, un fichier final n’existe que s’il est complet.

Section intitulée le-resultatLe résultat

Voici les chiffres, mesurés sur deux runs réels de notre CI (même machine, avant et après) :

Étape Sans cache (reconstruction) Avec cache (restauration)
Provisionnement de la base ~53 s ~6 s
Tâche fixtures complète (base + Redis + indexation ES) ~79 s ~14 s

Le provisionnement de la base (la partie qui reconstruisait les schémas, chargeait les fixtures géo et rejouait les fixtures métier) tombe de ~53 s à ~6 s : un gunzip piped dans mariadb, et c’est tout. Le reste de la tâche (rechargement Redis, réindexation Elasticsearch) tourne dans les deux cas, d’où les ~14 s résiduelles.

Pour la grande majorité des pull requests, celles qui ne touchent pas au modèle de données, la préparation de la base passe donc d’une minute à quelques secondes. Et le jour où l’on touche vraiment aux migrations ou aux fixtures, le cache se reconstruit tout seul, sans qu’on ait à y penser, parce que sa clé a changé.

Si vous ne deviez retenir qu’une chose, ce serait celle-ci : ce qui fait la valeur de ce cache, ce n’est ni la compression ni le mariadb-dump, c’est la clé. Un cache n’est utile que s’il est à la fois agressif (il évite un maximum de travail) et correct (il ne sert jamais de données périmées). En dérivant la clé du contenu exact qui produit la base, on obtient les deux d’un coup, et plus personne n’a à se demander « faut-il régénérer le dump ? ». La réponse est dans le hash.

Commentaires et discussions

Nos articles sur le même sujet