Search API Solr à 5 millions d’utilisateurs : datasource et tracker
La datasource entity:user de Search API fonctionne très bien — jusqu’à un certain point. Quelque part entre cent
mille et un million d’utilisateurs, la configuration que montrent tous les tutoriels cesse d’être lente pour
devenir inutilisable : les crons ne finissent jamais, une réindexation verrouille la base pendant une heure, et la
table de suivi devient le plus gros objet du schéma. Voici ce qui casse réellement, et les deux plugins qui règlent
le problème.
D’abord : avez-vous vraiment besoin de Solr ?
La version la plus fréquente de cette demande, c’est « permettre aux éditeurs de chercher des utilisateurs, filtrés par rôle ». Si c’est réellement tout ce qu’il vous faut, une requête SQL bien indexée bat Solr : dix fois moins de travail, aucune synchronisation à rater, et pas de seconde copie de vos données personnelles à sécuriser. Ajoutez un index sur les colonnes filtrées et triées, faites une vue, passez à autre chose.
Solr justifie sa place quand vous avez besoin de recherche plein texte sur les champs de profil, de facettes sur des millions de lignes, ou de requêtes multicritères qu’aucun index SQL ne peut servir seul — le raisonnement qui a fait sortir le catalogue produits de DOGA de Search API Database. Si c’est votre cas, la suite est pour vous.
Où la configuration par défaut casse
Faites le calcul avant d’accuser Solr. Cinq millions d’utilisateurs, indexés par lots de 50 via l’API entité, cela fait 100 000 lots de cron ; à une seconde par lot — optimiste dès qu’il y a des champs, des hooks et des processeurs — une réindexation complète tourne autour de 28 heures. Pendant ce temps :
search_api_itemstocke une ligne par élément. À 5 millions de lignes, « tout marquer pour réindexation » est un uniqueUPDATEgigantesque qui verrouille la table pendant que le cron essaie de la lire.- Chaque passage de cron parcourt cette table pour trouver le lot suivant.
- La pagination par offset se dégrade quadratiquement : aller chercher la page 4 000 oblige MySQL à parcourir quatre millions de lignes pour en jeter 3 999 000.
La séparation qui compte
Le réflexe est d’écrire un tracker personnalisé qui « ne suit que les utilisateurs ayant le rôle ». C’est mettre la logique dans le mauvais plugin. Gardez les responsabilités propres :
- La datasource décide qui appartient à l’index. Le filtrage par rôle vit ici, dans
getItemIds(). - Le tracker ne fait que de la comptabilité — ce qui est périmé. On le remplace quand la table de suivi elle-même est le goulot d’étranglement, pas pour filtrer.
La datasource : pagination par curseur, pas par offset
Search API passe à getItemIds() un numéro de page ; toute l’astuce consiste à le traduire en curseur. La
datasource ContentEntity du core fait exactement cela — elle mémorise le dernier ID suivi dans le state plutôt
que de paginer par offset. La vôtre doit faire pareil :
public function getItemIds($page = NULL) {
$state_key = 'acme_user_search.cursor.' . $this->getIndex()->id();
$query = $this->database->select('users_field_data', 'u');
$query->fields('u', ['uid']);
$query->innerJoin('user__roles', 'r', 'r.entity_id = u.uid');
$query->condition('r.roles_target_id', $this->configuration['role']);
$query->condition('u.uid', 0, '>');
$query->condition('u.default_langcode', 1);
if ($page !== NULL) {
// La page 0 signifie « on repart de zéro » : Search API parcourt
// toujours les pages dans l'ordre.
$cursor = $page === 0 ? 0 : (int) $this->state->get($state_key, 0);
$query->condition('u.uid', $cursor, '>')->orderBy('u.uid')->range(0, 1000);
}
$uids = $query->execute()->fetchCol();
if ($page !== NULL) {
if (!$uids) {
$this->state->delete($state_key);
// NULL indique à Search API : cette page et toutes les suivantes
// sont vides.
return NULL;
}
$this->state->set($state_key, (int) end($uids));
}
return array_map('strval', $uids);
}
Une règle de plus pour loadMultiple() : revérifiez le rôle au chargement. Le suivi et l’indexation sont
séparés de plusieurs minutes ou heures, et sans cette vérification vous indexez joyeusement quelqu’un qui ne
remplit plus la condition.
Le bug que personne ne traite : les changements de rôle
Aucun des deux plugins ne remarque qu’un utilisateur entre ou sort de l’ensemble indexable. Un utilisateur reçoit le rôle et n’est jamais indexé ; un autre le perd et reste indéfiniment consultable. Il faut le câbler soi-même :
function acme_user_search_user_update(UserInterface $user) {
$had = in_array(ACME_ROLE, $user->original->getRoles(), TRUE);
$has = in_array(ACME_ROLE, $user->getRoles(), TRUE);
if ($had === $has) {
return;
}
$ids = [(string) $user->id()];
foreach (Index::loadMultiple() as $index) {
if ($index->isValidDatasource('user_by_role')) {
// Pas « mis à jour » mais « supprimé » : un update le réindexerait
// indéfiniment.
$has ? $index->trackItemsInserted('user_by_role', $ids)
: $index->trackItemsDeleted('user_by_role', $ids);
}
}
}
C’est celui qui ressort en constat de sécurité : d’anciens collaborateurs, toujours dans l’index, toujours trouvables.
Le tracker : arrêter d’écrire cinq millions de lignes
Là, le tracker mérite son plugin sur mesure. Plutôt qu’une ligne par élément, on déduit ce qui est périmé de la
colonne changed de la table source et on stocke un simple repère. Une réindexation complète devient une écriture
au lieu de cinq millions :
public function trackItemsInserted(array $ids) {}
public function trackItemsUpdated(array $ids) {}
public function trackAllItemsUpdated($datasource_id = NULL) {
// Réindexation complète : une ligne supprimée au lieu de cinq
// millions mises à jour.
$this->getState()->delete($this->key());
}
Deux pièges. D’abord, changed a une résolution à la seconde : une attribution de rôle en masse estampille
cinquante mille utilisateurs avec le même horodatage — comparez sur le couple (changed, uid) ou vous sautez
silencieusement tous ceux qui suivent la première égalité. Ensuite, un repère ne peut pas voir une ligne qui
n’existe plus : hook_user_delete() doit appeler trackItemsDeleted() directement, sinon l’index accumule des
fantômes.
Pensez aussi à mettre en cache getTotalItemsCount() et getRemainingItemsCount(). Ils s’exécutent à chaque
chargement des pages d’admin Search API, et une barre de progression approximative vaut mieux qu’une requête exacte
de 900 ms.
Avant la mise en production : le volet données personnelles
Tout ce qui précède place des adresses e-mail et des champs de profil dans Solr. Sans jetons de rôle écrits dans chaque document et appliqués à la requête, votre endpoint de recherche devient une API d’énumération d’utilisateurs — et votre sauvegarde Solr, une copie de votre table utilisateurs hors de tout ce qui encadre la base. Traitez l’index comme de la donnée personnelle, parce que c’en est.
En résumé
Filtrez dans la datasource, paginez par curseur, gérez les changements de rôle dans hook_user_update(), et ne
remplacez le tracker qu’une fois mesuré que search_api_item est bien le goulot d’étranglement. La plupart des
sites qui croient avoir besoin des quatre avaient en fait besoin de la requête SQL de la première section.
Vous exploitez Search API à une échelle où les tutoriels ne s’appliquent plus ? L’architecture et la performance des grandes plateformes Drupal sont mon métier — audit compris, celui qui vous dit lequel des quatre vous est réellement utile.