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.
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 :
- Si le plan contient quelque chose comme
/* traverse "cq:Page" */, la requête n’utilise aucun index — c’est une traversée complète du sous-arbre. - Si le plan nomme un index — par exemple quelque chose comme
/* lucene:cqPageLucene(/oak:index/cqPageLucene) ... */— la requête est résolue via cet index.
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
- La recherche d’auteur devient lente à l’échelle : un widget de
recherche touch UI ou un
granite:searchqui se comportait bien en QA se met à ramper — voire échoue purement et simplement — une fois déployé face au volume réel d’un DAM ou d’un arbre de contenu en production ; c’est presque toujours un prédicat ajouté tardivement qu’Oak ne peut pas résoudre avec les index existants. - Un job planifié qui “avait toujours fonctionné” : un
Scheduler/Runnableou un script de migration exécutant un JCR-SQL2 large (par exemple, “trouver toutes les pages utilisant encore le composant X”) peut fonctionner sans problème en local et se mettre à lever l’exception de traversée sur Cloud Service dès qu’il s’exécute face à du contenu réel. - Avant de supposer que c’est “un bug d’AEM” : reproduisez la requête en échec dans l’outil Query Performance / Explain Query face à un environnement au volume représentatif. Le plan révèle presque toujours une traversée ou un index mal exploité, et la solution consiste à restreindre la requête ou à ajouter le bon index — pas à relever les limites.