Aller au contenu
tak.tn
← Retour au blog
5 min de lecture

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_item stocke une ligne par élément. À 5 millions de lignes, « tout marquer pour réindexation » est un unique UPDATE gigantesque 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.

Un projet Drupal ou un besoin SEO technique ?

Parlons-en. Réponse sous 24 h ouvrées, sans engagement.

Me contacter