Interrogare i contenuti in AEM: JCR-SQL2, QueryBuilder e perché serve un indice Oak
Come scegliere tra QueryBuilder e JCR-SQL2 per interrogare i contenuti in AEM, perché una query senza un indice Oak corrispondente diventa una traversal, e perché questo può fallire su AEM as a Cloud Service anche se funziona in locale.
In un progetto AEM esistono due modi comuni per interrogare i contenuti:
l’API QueryBuilder (quella che alimenta il Content Finder, la ricerca
asset e la maggior parte dei widget di ricerca di Granite UI) e JCR-SQL2
scritto a mano ed eseguito contro una Session/ResourceResolver da
codice Java. Entrambe finiscono nello stesso punto: il motore di query di
Apache Jackrabbit Oak. Capire questo — e capire quando Oak ha davvero un
indice per risolvere la tua query e quando no — è ciò che distingue una
ricerca in author che risponde in millisecondi da un job pianificato che
esplode in produzione.
Due modi per interrogare i contenuti in AEM
QueryBuilder (com.day.cq.search.QueryBuilder) è l’API propria di AEM
per costruire query a partire da predicati dichiarativi invece che da
sintassi SQL. È quella che alimenta il Content Finder, l’Asset Search e
praticamente ogni widget granite:search in un dialog 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();
// lavorare con la resource
}
L’alternativa è scrivere JCR-SQL2 direttamente ed eseguirlo contro il
ResourceResolver (che internamente delega a una Session JCR). È il
percorso abituale in scheduler, workflow, listener e script di
migrazione, dove costruire un PredicateGroup per una query occasionale
non vale la pena:
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);
Nessuno dei due approcci “aggira” la necessità di un indice. QueryBuilder
traduce i suoi predicati in una query che Oak elabora esattamente come un
JCR-SQL2 scritto a mano — la differenza è che i predicati nascondono la
struttura della query risultante, rendendo più facile combinare predicati
che, insieme, non sono coperti da nessun indice, senza accorgersene finché
la query non è già in esecuzione in produzione.
Vale anche la pena ricordare che sia Hit.getResource() sia
findResources() restituiscono Resource ottenute tramite il
ResourceResolver usato per lanciare la query: se quel resolver è uno che
hai aperto tu (ad esempio con getServiceResourceResolver), vale sempre
la stessa regola: chiuderlo sempre in un blocco try-with-resources.
Perché l’indice sottostante conta: il motore di query basato sul costo di Oak
Oak non esegue le query in modo ingenuo contro l’albero dei contenuti. Usa un ottimizzatore basato sul costo: chiede a ogni indice disponibile quanto costerebbe risolvere la query (un numero tra 1 — una ricerca puntuale molto economica — e infinito se l’indice non può aiutare affatto) e sceglie l’indice più economico.
Il problema si presenta quando nessun indice può risolvere la query. In quel caso, Oak ricorre a una traversal: attraversa nodo per nodo l’intero sottoalbero indicato dalla query, valutando la condizione uno alla volta, invece di saltare direttamente ai risultati corrispondenti. Quando questo accade, Oak registra un avviso nel log:
Traversal query (query without index): {statement}; consider creating an index
Una traversal non è un errore di sintassi né un bug: è una query
perfettamente valida che Oak può comunque risolvere, semplicemente a un
costo che cresce linearmente con il numero di nodi del sottoalbero.
Contro /content/we-retail sulla tua istanza di author locale, con
qualche pagina di esempio, questo può richiedere pochi millisecondi e
passare completamente inosservato in sviluppo.
Perché questo è particolarmente pericoloso su AEM as a Cloud Service
Lo stesso comportamento, invisibile in locale, diventa un problema reale a fronte del volume di contenuti di produzione. Più pagine, asset o nodi ci sono nel sottoalbero attraversato, più costosa è la traversal — e questo costo non significa solo “più lento”: Oak impone un limite di lettura predefinito. Quando una query legge o attraversa più di 100.000 nodi, si ferma e lancia un’eccezione:
The query read or traversed more than 100000 nodes.
To avoid affecting other tasks, processing was stopped.
Esiste un limite equivalente per l’ordinamento in memoria dei risultati
(quando un ORDER BY non può essere risolto tramite un indice), con un
messaggio simile oltre i 500.000 nodi letti in memoria. Questi limiti sono
attivi per default fin da AEM 6.3, proprio per evitare che una query mal
indicizzata consumi risorse del repository senza controllo e influisca su
altri task concorrenti (altre query, replica, indicizzazione asincrona).
Questo ha una conseguenza molto concreta per uno sviluppatore AEM: una query che “funziona” contro un’istanza author locale con contenuti di esempio può fallire direttamente — non solo diventare lenta — non appena viene distribuita a fronte del volume reale di contenuti di un sito in produzione. E su AEM as a Cloud Service, dove non hai accesso a livello di sistema per alzare semplicemente quella soglia come rimedio rapido, la risposta supportata non è toccare il limite — è indicizzare o limitare la query. La stessa documentazione sulle best practice di query e indicizzazione di AEM as a Cloud Service è esplicita nel richiedere che ogni query venga “spiegata” (explain) prima di andare in produzione e non mostri una traversal nel suo piano di esecuzione.
Verificare se la tua query usa un indice
AEM espone uno strumento Query Performance (noto anche come “Explain
Query”) all’interno della dashboard Operations, in
/libs/granite/operations/content/diagnosistools/queryPerformance.html.
Su AEM as a Cloud Service, la vista equivalente si raggiunge tramite il
Developer Console di Cloud Manager. Lì puoi incollare XPath, JCR-SQL2 o
l’istruzione generata da una chiamata QueryBuilder e ottenere il piano
di esecuzione reale:
- Se il piano contiene qualcosa come
/* traverse "cq:Page" */, la query non sta usando alcun indice — è una traversal completa del sottoalbero. - Se il piano nomina un indice — ad esempio qualcosa come
/* lucene:cqPageLucene(/oak:index/cqPageLucene) ... */— la query viene risolta tramite quell’indice.
Lo strumento assegna anche un punteggio “Read Optimization” alle query (il
rapporto tra nodi scansionati e nodi che corrispondono realmente al
risultato): una query ben indicizzata di solito ottiene un punteggio
intorno al 90% o superiore; un punteggio basso indica che, anche se la
query usa tecnicamente un indice, sta ancora leggendo molti più nodi del
necessario — ad esempio perché l’ORDER BY non è coperto dall’indice e
Oak deve ordinare in memoria.
Per le query costruite specificamente con QueryBuilder, in un ambiente
di sviluppo locale puoi usare la console di debug di QueryBuilder in
/libs/cq/search/content/querydebug.html, che permette di eseguire un
insieme di predicati e vedere sia i risultati sia il piano risultante
prima di scrivere lo stesso PredicateGroup nel codice Java.
Quando un indice Oak personalizzato è davvero giustificato
Non ogni query lenta ha bisogno di un nuovo indice. AEM include già
indici Lucene per i casi più comuni — pagine (cqPageLucene), asset
(damAssetLucene), tag, tipo di nodo — che coprono la maggior parte delle
ricerche per sling:resourceType, template, jcr:primaryType o percorsi
di tag. Prima di creare un tuo indice, usa lo strumento Explain Query per
verificare se uno degli indici esistenti copre già la combinazione di
restrizioni di cui hai bisogno: un indice ridondante non migliora nulla e
aggiunge un costo di indicizzazione a ogni scrittura nel repository.
Un indice personalizzato è giustificato quando filtri o ordini per
proprietà specifiche del tuo progetto che nessun indice out-of-the-box
copre — ad esempio una combinazione di metadati DAM personalizzati, o un
ORDER BY su una proprietà di business che non fa parte degli indici
standard. In un progetto AEM, la definizione dell’indice viene distribuita
come contenuto immutabile all’interno di un package (tipicamente
ui.apps o un modulo dedicato agli indici), puntando al nodo
/oak:index/<nome>:
<!-- 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>
Su AEM as a Cloud Service non esiste CRXDE Lite in produzione per modificare un indice a mano: la definizione viaggia insieme al codice, passa attraverso la pipeline di Cloud Manager e deve essere validata su un ambiente di staging con un volume di contenuti rappresentativo prima di arrivare in produzione — proprio perché il comportamento di una query a fronte di 200 pagine di esempio e a fronte di 200.000 pagine reali può essere radicalmente diverso.
Dove questo emerge in un vero progetto AEM
- La ricerca in author diventa lenta scalando: un widget di ricerca
touch UI o un
granite:searchche si comportava bene in QA inizia a trascinarsi — o fallisce direttamente — una volta distribuito a fronte del volume reale di un DAM o di un albero di contenuti in produzione; quasi sempre è dovuto a un predicato aggiunto tardi che Oak non riesce a risolvere con gli indici esistenti. - Un job pianificato che “aveva sempre funzionato”: uno
Scheduler/Runnableo uno script di migrazione che esegue un JCR-SQL2 ampio (ad esempio, “trova tutte le pagine che usano ancora il componente X”) può funzionare senza problemi in locale e iniziare a lanciare l’eccezione di traversal su Cloud Service non appena viene eseguito a fronte di contenuti reali. - Prima di assumere che sia “un bug di AEM”: riproduci la query fallita nello strumento Query Performance / Explain Query a fronte di un ambiente con volume rappresentativo. Il piano rivela quasi sempre una traversal o un indice sfruttato male, e la soluzione è limitare la query o aggiungere l’indice giusto — non alzare i limiti.