Résolution de démarrage à froid de Cal.com

Contexte
Depuis quelque temps, l'application Cal.com pouvait souffrir de longs temps de chargement à froid allant de 7 secondes à 30 secondes, dans les cas extrêmes. Ce comportement ne se produisait pas toujours en raison de la nature des démarrages à froid, mais il se produisait suffisamment souvent pour que cela devienne un problème que nous devions résoudre dès que possible.
Pour ceux qui ne sont pas familiers avec les démarrages à froid, il existe de nombreux matériaux décrivant le problème en général, donc nous ne couvrirons pas cela. Ce que nous espérons faire avec ce blog, c'est expliquer la cause fondamentale exacte qui affectait notre système en particulier, qui était induite par des démarrages à froid dans un environnement sans serveur.
Conclusions et Expérimentations
Pour commencer, examinons le log suivant de Vercel :

Pour la fonction /api/trpc/[trpc]
, nous voyons une Durée de Démarrage à Froid de 298 ms mais une Durée d'Exécution de 7,57 s. C'est un peu étrange, non ? Le démarrage à froid lui-même n'est pas le problème, mais plutôt, c'est ce qui se charge lors du démarrage à froid qui provoque une inflation importante de la Durée d'Exécution.
Alors, par où commencer pour trouver la cause profonde de cela ?
Hypothèse 1 : Taille du paquet de fonction
Après avoir vu la Taille de Fonction de 33,1 Mo, notre équipe, la communauté et les gens de Vercel et Prisma ont directement plongé et ont commencé des analyses de paquet et des journaux de compilation pour essayer de trouver quels paquets alourdissaient nos fonctions. Même si cette Taille de Fonction est inférieure à la limite de 50 Mo, nous croyions qu'elle pourrait tout de même en être en partie la coupable.
Nous avons identifié quelques grandes dépendances chargées dans le cadre de notre récupération de session qui pouvaient être allégées (PR) et nous avons chargé paresseusement nos dépendances de l'application store (PR).
Les performances se sont améliorées à chaque requête suite à ces mises à jour, mais ce n'était pas la réduction de plusieurs secondes que nous espérions. Alors, qu'est-ce que cela pourrait être d'autre ? Est-ce que Prisma est lent pour se connecter lors d'un démarrage à froid dans notre configuration sans serveur ?
Hypothèse 2 : Prisma
Nous avions déjà Prisma Data Proxy en fonctionnement en production pour gérer notre mise en pool de connexion, donc nous étions sceptiques que ce soit le problème, mais néanmoins, nous voulions faire notre diligence raisonnable pour nous en assurer.
Sur une branche expérimentale, nous avons lancé un point de terminaison de test séparé de nos routes et pages API dans une route app séparée (en utilisant le nouveau Router d'App de Next.js) qui ne récupérait que le nombre d'utilisateurs dans le système. Les performances de cette route lors des démarrages à froid pouvaient atteindre jusqu'à 1 seconde, ce qui nous montrait que Prisma pouvait avoir un léger retard lors des démarrages à froid, mais encore une fois, ce n'était pas les 7-15 secondes que nous voyions en moyenne.
Hypothèse 3 : _app et _document
À partir de l'analyse du paquet qui avait été réalisée, il était clair que notre application charge beaucoup de dépendances. Cela a déclenché l'idée de réduire ce qui était chargé à chaque requête API. Sachant que les routes API dans Next.js chargent le fichier _app.tsx, nous avons décidé de mettre en œuvre un PageWrapper qui contient toutes les dépendances nécessaires pour les routes non-API (PR).
Encore une fois, c'est une belle amélioration en termes d'optimisation des dépendances, mais nous avions encore nos longs temps d'attente.
Cause Racine
Après avoir expédié énormément de PRs spécifiques à la performance qui ont aidé à l'organisation du code, à la vitesse et à la taille du paquet, nous n'avions toujours pas trouvé le « graal ». Il était temps de revenir sur nous-mêmes et de penser en termes de goulets d'étranglement, car de nombreux problèmes de performance sont liés à des goulets d'étranglement, n'est-ce pas ?
En suivant ce raisonnement, un test a été effectué pour diviser le routeur tRPC le plus simple dans sa propre route API. Pour nous, c'était notre routeur public. Après déploiement, le point de terminaison /api/trpc/viewer.public.i18n
était désormais /api/trpc/public/i18n
. Nous avons constaté une diminution des temps de démarrage à froid de 15 secondes à 2 secondes pour cette route. Cela semblait suggérer que notre unique routeur tRPC était en effet le goulet d'étranglement.
En d'autres termes, nous avions trop de routeurs tRPC entrant dans le routeur principal exposé sur la route /api/trpc/[trpc]
. Cela signifiait que l'appel initial à cette fonction, qu'il s'agisse de charger des termes i18n ou d'obtenir l'emploi du temps d'un utilisateur, devait importer tous les 20 routeurs et toutes leurs dépendances.
Vu ici dans ce qui a finalement été le PR qui a résolu la plupart de nos problèmes de longs démarrages à froid, il y avait une suspicion que le routeur de slots était trop gros et était à l'origine des problèmes. Malheureusement, après avoir divisé le routeur de slots dans sa propre route API Next.js, nous voyions toujours de grandes tailles de paquets et de longs temps de démarrage à froid. Pour ces raisons, ce PR est resté intact pendant que d'autres hypothèses étaient explorées.
Après la découverte que la route i18n se chargeait en 2 secondes lors de démarrages à froid, le PR susmentionné a ensuite été élargi afin que tous les routeurs tRPC aient leur propre route API. Lorsqu'ils sont divisés, Vercel crée des fonctions séparées par route.
Alors, pourquoi le fait de diviser les routeurs tRPC dans leurs propres routes API Next.js résout-il le problème ? La réponse simple, dans notre cas, est que même si une page appelle 5 routes tRPC différentes pour charger des données, elles s'exécuteront simultanément et donc l'effet de chargement des dépendances pour tous les routeurs n'est pas cumulé. Nous pouvons maintenant toujours voir des temps de démarrage à froid de 2 à 3 secondes sur n'importe quelle route tRPC, mais comme elles sont appelées simultanément par le navigateur, le temps d'attente total peut être de 3 secondes, pas 7-15 secondes (ou 30 secondes dans les cas extrêmes).
Nous reconnaissons qu'il reste encore du travail à faire pour réduire ces vitesses encore plus, mais les réductions jusqu'à présent ont été significatives.
Voici un problème pour promouvoir les changements potentiels pour tRPC dans des environnements sans serveur.
Résultats Clés

Remerciements
Un grand merci à tous ceux qui ont contribué à résoudre ce problème. Du fait d'écrire des scripts personnalisés qui ont aidé à déterminer quelles dépendances encombraient notre système à l'ouverture de PRs, nous apprécions vraiment le soutien et les efforts déployés pour résoudre ces problèmes. Nous espérons que ce blog et les PRs peuvent fournir un aperçu des expériences que nous avons menées et de la cause racine que nous avons finalement trouvée.
Un merci spécial à Julius Marminge de tRPC.io, Guillermo Rauch, Malte Ubl et Jimmy Lai de Vercel et Lucas Smith. 🙏
Et après ?
Bien que cela ait été une grande réussite, nous continuons à travailler dur pour réduire encore plus les démarrages à froid mais aussi améliorer l'expérience de self-hosting sans architecture sans serveur. Restez à l'écoute !