AEM Guide

Interroger le contenu dans AEM : JCR-SQL2, QueryBuilder et pourquoi un index Oak est indispensable

Comment choisir entre QueryBuilder et JCR-SQL2 pour interroger le contenu dans AEM, pourquoi une requête sans index Oak correspondant devient une traversée, et pourquoi cela peut échouer sur AEM as a Cloud Service même quand cela fonctionne en local.

jcroakquerybuilderjcr-sql2performance

Dans un projet AEM, il existe deux façons courantes d’interroger le contenu : l’API QueryBuilder (celle qui alimente le Content Finder, la recherche d’assets et la plupart des widgets de recherche de Granite UI) et du JCR-SQL2 écrit à la main, exécuté contre une Session/un ResourceResolver depuis du code Java. Les deux finissent au même endroit : le moteur de requêtes d’Apache Jackrabbit Oak. Comprendre cela — et comprendre quand Oak dispose réellement d’un index pour résoudre votre requête et quand ce n’est pas le cas — c’est ce qui distingue une recherche d’auteur qui répond en quelques millisecondes d’un job planifié qui explose en production.

Deux façons d’interroger le contenu dans AEM

QueryBuilder (com.day.cq.search.QueryBuilder) est l’API propre à AEM pour construire des requêtes à partir de prédicats déclaratifs plutôt que d’une syntaxe SQL. C’est elle qui alimente le Content Finder, la recherche d’assets et pratiquement tous les widgets granite:search d’une boîte de dialogue touch UI :

import com.day.cq.search.PredicateGroup;
import com.day.cq.search.Query;
import com.day.cq.search.QueryBuilder;
import com.day.cq.search.result.Hit;
import com.day.cq.search.result.SearchResult;

Map<String, String> params = new HashMap<>();
params.put("path", "/content/we-retail");
params.put("type", "cq:Page");
params.put("property", "jcr:content/cq:template");
params.put("property.value", "/conf/we-retail/settings/wcm/templates/product-page");
params.put("p.limit", "20");

QueryBuilder queryBuilder = resourceResolver.adaptTo(QueryBuilder.class);
Query query = queryBuilder.createQuery(PredicateGroup.create(params), session);
SearchResult result = query.getResult();

for (Hit hit : result.getHits()) {
    Resource page = hit.getResource();
    // travailler avec la resource
}

L’alternative consiste à écrire du JCR-SQL2 directement et à l’exécuter contre le ResourceResolver (qui délègue en interne à une Session JCR). C’est le chemin habituel dans les schedulers, les workflows, les listeners et les scripts de migration, où construire un PredicateGroup pour une requête ponctuelle n’en vaut pas la peine :

import javax.jcr.query.Query;

String statement =
    "SELECT * FROM [cq:Page] AS page " +
    "WHERE ISDESCENDANTNODE(page, [/content/we-retail]) " +
    "AND [jcr:content/cq:template] = " +
    "'/conf/we-retail/settings/wcm/templates/product-page'";

Iterator<Resource> pages = resourceResolver.findResources(statement, Query.JCR_SQL2);

Aucune des deux approches ne “contourne” le besoin d’un index. QueryBuilder traduit ses prédicats en une requête qu’Oak traite exactement comme un JCR-SQL2 écrit à la main — la différence est que les prédicats masquent la structure de la requête résultante, ce qui rend plus facile de combiner des prédicats qui, ensemble, ne sont couverts par aucun index, sans s’en rendre compte avant que la requête ne tourne déjà en production.

Il faut aussi se rappeler que Hit.getResource() comme findResources() renvoient des Resource obtenues via le ResourceResolver utilisé pour exécuter la requête. Si ce resolver est un resolver que vous avez ouvert vous-même (par exemple via getServiceResourceResolver), la même règle s’applique toujours : toujours le fermer dans un bloc try-with-resources.

Pourquoi l’index sous-jacent compte : le moteur de requêtes basé sur le coût d’Oak

Oak n’exécute pas les requêtes naïvement contre l’arbre de contenu. Il utilise un optimiseur basé sur le coût : il demande à chaque index disponible combien il lui en coûterait de résoudre la requête (un nombre entre 1 — une recherche ponctuelle très bon marché — et l’infini si l’index ne peut absolument pas aider) et choisit l’index le moins cher.

Le problème survient quand aucun index ne peut résoudre la requête. Dans ce cas, Oak se rabat sur une traversée : il parcourt nœud par nœud tout le sous-arbre indiqué par la requête, en évaluant la condition un par un, au lieu de sauter directement aux résultats correspondants. Quand cela se produit, Oak consigne un avertissement dans le log :

Traversal query (query without index): {statement}; consider creating an index

Une traversée n’est ni une erreur de syntaxe ni un bug : c’est une requête parfaitement valide qu’Oak peut toujours résoudre, simplement à un coût qui croît linéairement avec le nombre de nœuds du sous-arbre. Sur /content/we-retail de votre instance d’auteur locale, avec quelques pages d’exemple, cela peut prendre quelques millisecondes et passer totalement inaperçu en développement.

Pourquoi c’est particulièrement dangereux sur AEM as a Cloud Service

Le même comportement, invisible en local, devient un vrai problème face au volume de contenu de production. Plus le sous-arbre parcouru contient de pages, d’assets ou de nœuds, plus la traversée coûte cher — et ce coût n’est pas juste “plus lent” : Oak impose une limite de lecture par défaut. Quand une requête lit ou parcourt plus de 100 000 nœuds, elle s’arrête et lève une exception :

The query read or traversed more than 100000 nodes.
To avoid affecting other tasks, processing was stopped.

Il existe une limite équivalente pour le tri en mémoire des résultats (quand un ORDER BY ne peut pas être résolu via un index), avec un message similaire au-delà de 500 000 nœuds lus en mémoire. Ces limites sont activées par défaut depuis AEM 6.3, précisément pour empêcher qu’une requête mal indexée ne consomme les ressources du repository sans limite et n’affecte les autres tâches concurrentes (autres requêtes, réplication, indexation asynchrone).

Cela a une conséquence très concrète pour un développeur AEM : une requête qui “fonctionne” sur une instance d’auteur locale avec du contenu d’exemple peut échouer purement et simplement — pas seulement devenir lente — dès qu’elle est déployée face au volume de contenu réel d’un site en production. Et sur AEM as a Cloud Service, où vous n’avez pas d’accès au niveau système pour simplement relever ce seuil comme solution rapide, la réponse prise en charge n’est pas de toucher la limite — c’est d’indexer ou de restreindre la requête. La documentation de bonnes pratiques de requêtes et d’indexation d’AEM as a Cloud Service elle-même insiste sur le fait que toute requête doit être “expliquée” avant sa mise en production et ne doit pas afficher de traversée dans son plan d’exécution.

Vérifier si votre requête utilise un index

AEM expose un outil Query Performance (aussi appelé “Explain Query”) dans le tableau de bord Operations, à /libs/granite/operations/content/diagnosistools/queryPerformance.html. Sur AEM as a Cloud Service, la vue équivalente est accessible via le Developer Console de Cloud Manager. Vous pouvez y coller du XPath, du JCR-SQL2, ou l’instruction générée par un appel QueryBuilder, et obtenir le plan d’exécution réel :

L’outil note également les requêtes selon un score de “Read Optimization” (le rapport entre les nœuds scannés et les nœuds qui correspondent réellement au résultat) : une requête bien indexée obtient généralement un score d’environ 90 % ou plus ; un score faible indique que, même si la requête utilise techniquement un index, elle lit encore bien plus de nœuds que nécessaire — par exemple parce que le ORDER BY n’est pas couvert par l’index et qu’Oak doit trier en mémoire.

Pour les requêtes construites spécifiquement avec QueryBuilder, sur un environnement de développement local vous pouvez utiliser la console de debug QueryBuilder à /libs/cq/search/content/querydebug.html, qui permet d’exécuter un ensemble de prédicats et de voir à la fois les résultats et le plan obtenu avant d’écrire ce même PredicateGroup dans du code Java.

Quand un index Oak personnalisé est réellement justifié

Toute requête lente n’a pas besoin d’un nouvel index. AEM fournit déjà des index Lucene pour les cas les plus courants — pages (cqPageLucene), assets (damAssetLucene), tags, type de nœud — couvrant la plupart des recherches par sling:resourceType, template, jcr:primaryType ou chemins de tags. Avant de créer votre propre index, utilisez l’outil Explain Query pour vérifier si l’un des index existants couvre déjà la combinaison de restrictions dont vous avez besoin : un index redondant n’améliore rien et ajoute un coût d’indexation à chaque écriture dans le repository.

Un index personnalisé se justifie quand vous filtrez ou triez sur des propriétés spécifiques à votre projet qu’aucun index out-of-the-box ne couvre — par exemple une combinaison de métadonnées DAM personnalisées, ou un ORDER BY sur une propriété métier qui ne fait pas partie des index standards. Dans un projet AEM, la définition de l’index est déployée sous forme de contenu immuable dans un package (généralement ui.apps ou un module dédié aux index), ciblant le nœud /oak:index/<nom> :

<!-- jcr_root/_oak_index/myprojectAssetLucene/.content.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:jcr="http://www.jcp.org/jcr/1.0"
    jcr:primaryType="oak:QueryIndexDefinition"
    type="lucene"
    async="async"
    compatVersion="{Long}2"
    includedPaths="[/content/dam]">
    <indexRules jcr:primaryType="nt:unstructured">
        <dam:Asset jcr:primaryType="nt:unstructured">
            <properties jcr:primaryType="nt:unstructured">
                <projectCategory
                    jcr:primaryType="nt:unstructured"
                    name="jcr:content/metadata/projectCategory"
                    propertyIndex="{Boolean}true"/>
            </properties>
        </dam:Asset>
    </indexRules>
</jcr:root>

AEM as a Cloud Service ne propose pas de CRXDE Lite en production pour modifier un index à la main : la définition voyage avec votre code, passe par le pipeline Cloud Manager, et doit être validée sur un environnement de staging avec un volume de contenu représentatif avant d’atteindre la production — précisément parce que le comportement d’une requête face à 200 pages d’exemple et face à 200 000 pages réelles peut être radicalement différent.

Où cela se manifeste dans un vrai projet AEM