02Performance5 min

Les listes d'admin sont la première chose qui lâche

Le panneau interne s'écrit sans limites parce qu'au début il y a quatre lignes. C'est précisément là que tout explose quand le produit grandit.

Par Salva Castells

Image de couverture : Marijovanni, CC BY-SA 4.0.
Dans cet article

Un panneau d'administration commence toujours pareil : une table, quatre lignes de test et un select sans where. Ça marche. Et comme ça marche, personne n'y revient. Pendant ce temps, la table des utilisateurs passe de quatre lignes à quarante mille, et la page interne que personne ne mesure devient la plus lente du produit.

J'ai dû corriger ça sur quatre projets différents, et toujours pour les trois mêmes raisons.

L'admin s'écrit sans soin parce que personne ne le voit

La page publique a de la pagination dès le premier jour. Quelqu'un la relit, quelqu'un la mesure, il y a une métrique Core Web Vitals qui hurle si elle est lente. L'admin n'a rien de tout ça. Deux personnes l'utilisent, et ces deux personnes supposent que c'est lent parce que « c'est l'admin ».

Dans une API de transcription que je maintiens, les actions serveur getAdminUsers et getAdminFeedback recevaient limit en paramètre et le passaient directement à la requête. Sans plafond. Personne n'y avait touché depuis des mois parce que depuis l'UI il arrivait toujours un 10. Le problème n'est pas l'UI : c'est qu'une server action est un endpoint. N'importe qui avec une session admin, ou n'importe quel script, pouvait demander limit=1000000 et matérialiser toute la table dans la mémoire du worker.

La correction tenait en trois lignes, dans un lot de sécurité :

export const ADMIN_LIST_MAX_LIMIT = 1000;

const cappedLimit = Math.min(Math.max(limit, 1), ADMIN_LIST_MAX_LIMIT);

Le Math.max(limit, 1) n'est pas de la paranoïa gratuite : un limit négatif ou nul fait aussi des choses bizarres selon le driver. L'important, c'est où vit le plafond. Pas dans le composant, pas dans le hook : dans la couche qui reçoit la requête. Tout ce qui est au-dessus est une suggestion.

Le count qui a l'air gratuit et ne l'est pas

La pagination classique a besoin de savoir combien il y a de pages, et pour ça il faut un SELECT COUNT(*). Vous l'écrivez sans réfléchir parce que dans votre tête un count est bon marché. Il ne l'est pas : dans Postgres, un count exact parcourt toute la table ou tout l'index, et il n'y a aucun moyen d'y échapper.

Dans un gestionnaire documentaire interne, l'admin des utilisateurs faisait trois choses en parallèle à chaque chargement de page : récupérer la page de profils, récupérer toutes les sociétés pour le sélecteur, et compter le total de profils. Avec un pageSize de 5. Autrement dit, on parcourait toute la table pour afficher « page 3 sur 47 ».

J'ai retiré le count et changé le contrat de la pagination :

setHasMore(profilesData.length === pageSize);

On perd le « sur 47 ». On gagne une requête toujours proportionnelle à la taille de page. Le bouton suivant dépend maintenant de hasMore au lieu de currentPage < totalPages, et la plage affichée se calcule avec ce que vous avez déjà en main, sans rien demander de plus :

const endIndex = startIndex + companies.length - 1;

Personne ne s'est plaint de ne pas voir le total. C'est une donnée qui, dans un panneau interne, ne décide absolument rien. Si vous en avez vraiment besoin, reltuples de pg_class vous donne une estimation à coût nul ; pour « il y a environ 40 000 utilisateurs », c'est largement suffisant.

Récupérer des métadonnées plutôt que l'objet entier

Le troisième motif est plus subtil. Une liste de fichiers se charge bien jusqu'à ce que les fichiers aient du contenu. Dans un outil de notes collaboratives, la barre latérale appelait getProjectTextFiles, qui faisait un select sans projection : elle ramenait donc aussi la colonne content de chaque fichier du projet. Pour afficher une liste de noms.

La solution est ennuyeuse et elle marche : une seconde fonction qui sélectionne explicitement les colonnes dont la liste a besoin.

return await db
  .select({
    id: textFiles.id,
    projectId: textFiles.projectId,
    name: textFiles.name,
    isTemp: textFiles.isTemp,
    deletedAt: textFiles.deletedAt,
    createdAt: textFiles.createdAt,
    updatedAt: textFiles.updatedAt,
  })
  .from(textFiles)
  .where(and(eq(textFiles.projectId, projectId), isNull(textFiles.deletedAt)));

Le contenu est demandé au moment de sélectionner un fichier, pas avant. Et là apparaît le détail qu'on ne voit qu'une fois qu'on l'a fait : si l'utilisateur clique vite sur deux fichiers, la réponse lente du premier peut écraser le second. Il faut vérifier, avant d'écrire dans le store, que le fichier est toujours celui qui est sélectionné :

if (fullFile && useAppStore.getState().selectedFileId === fileId) {
  useAppStore.getState().setSelectedFile(fullFile);
}

Sans cette vérification, vous échangez un problème de performance contre un bug de course, ce qui est pire parce qu'il n'est pas reproductible à volonté.

Le bug qui n'apparaît que dans le panneau d'admin

L'un des meilleurs exemples de pourquoi l'admin mérite de l'attention m'a été donné par un tableau de bord d'analytique que j'avais mis en cache avec unstable_cache. Ça marchait en local et à la première visite en production. Ensuite ça plantait avec Invalid time value.

unstable_cache sérialise son payload en JSON. Une Date écrite lors de l'exécution à froid revient en string à toutes les lectures en cache. Et Intl.DateTimeFormat.format n'accepte pas une chaîne. Le type mentait : lastIngestedAt: Date | null était en réalité Date | string | null. TypeScript ne pouvait pas le voir parce que la frontière de sérialisation n'est pas typée.

Je l'ai corrigé en normalisant les deux formes et en traitant l'inaffichable comme une absence, pas comme une exception :

const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : formatter.format(date);

Une donnée de date qui ne peut pas être affichée doit apparaître comme « sans données », pas faire tomber toute la page. Dans un tableau de bord, une métrique cassée ne peut pas emporter les douze autres.

Ce que j'applique aujourd'hui à n'importe quelle liste interne

  • Un plafond dans la couche serveur. Un limit qui vient de l'extérieur est borné là où il entre, avec une constante nommée. L'UI n'est pas une validation.
  • Pas de count par défaut. Pagination par hasMore. Si quelqu'un demande le total, on lui donne une estimation, et seulement s'il la demande.
  • Projection explicite. Une liste sélectionne des colonnes, jamais *. Le blob se demande à l'ouverture du détail.
  • Un index sur la colonne de filtrage. Dans une application de pointage il y avait des index composites (user_id, date), mais les requêtes qui filtraient uniquement sur user_id ne les exploitaient pas bien ; il a fallu ajouter l'index simple.

Le panneau d'admin est l'endroit que l'équipe regarde quand quelque chose va mal en production. Que ce soit justement la partie du système qui ne tient pas le volume de production est une ironie coûteuse.