Consultar contenido en AEM: JCR-SQL2, QueryBuilder y por qué necesitas un índice de Oak
Cómo elegir entre QueryBuilder y JCR-SQL2 al consultar contenido en AEM, por qué una query sin índice de Oak se convierte en una traversal, y por qué eso puede fallar en AEM as a Cloud Service aunque funcione en local.
En un proyecto AEM hay dos formas habituales de consultar contenido: la API
QueryBuilder (la que usan el Content Finder, el buscador de assets y la
mayoría de los componentes de búsqueda de Granite UI) y JCR-SQL2 escrito a
mano contra una Session/ResourceResolver desde código Java. Ambas
terminan en el mismo sitio: el motor de queries de Apache Jackrabbit Oak.
Entender eso —y entender cuándo Oak tiene un índice para resolver tu query
y cuándo no— es lo que separa una búsqueda de autor que responde en
milisegundos de un job programado que revienta en producción.
Dos formas de consultar contenido en AEM
QueryBuilder (com.day.cq.search.QueryBuilder) es la API específica de
AEM pensada para construir queries a partir de predicados declarativos en
vez de sintaxis SQL. Es la que alimenta el Content Finder, el Asset
Search y prácticamente cualquier granite:search de un diálogo de 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();
// trabajar con el resource
}
La alternativa es escribir JCR-SQL2 directamente y ejecutarlo contra el
ResourceResolver (que internamente delega en una Session de JCR). Es el
camino habitual en schedulers, workflows, listeners y scripts de
migración, donde no tiene sentido montar un PredicateGroup para una
consulta puntual:
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);
Ninguna de las dos formas “esquiva” la necesidad de un índice. QueryBuilder
traduce sus predicados a una query que Oak procesa exactamente igual que
un JCR-SQL2 escrito a mano; la diferencia es que los predicados ocultan la
estructura de la query, así que es más fácil combinar predicados que,
juntos, no tienen ningún índice que los cubra sin que te des cuenta hasta
que la query ya está en producción.
También conviene recordar que tanto Hit.getResource() como
findResources() devuelven Resource obtenidos a través del
ResourceResolver que usaste para lanzar la query: si ese resolver es uno
que abriste tú (por ejemplo con getServiceResourceResolver), sigue
aplicando la misma regla de cerrarlo siempre en un try-with-resources.
Por qué el índice subyacente importa: el motor de queries de Oak
Oak no ejecuta las queries de forma ingenua contra el árbol de contenido. Usa un optimizador basado en coste: le pregunta a cada índice disponible cuánto le costaría resolver la query (un número entre 1 —una búsqueda puntual muy barata— e infinito si el índice no puede ayudar en absoluto) y elige el más barato.
El problema aparece cuando ningún índice puede resolver la query. En ese caso Oak recurre a una traversal: recorre nodo a nodo todo el subárbol indicado en la query, evaluando la condición uno por uno, en lugar de saltar directamente a los resultados que coinciden. Cuando esto ocurre, Oak registra un aviso en el log:
Traversal query (query without index): {statement}; consider creating an index
Una traversal no es un error de sintaxis ni un bug: es una query
perfectamente válida que Oak sigue pudiendo resolver, solo que pagando un
coste que crece linealmente con el número de nodos del subárbol. Contra
/content/we-retail en tu instancia de autor local, con un puñado de
páginas de ejemplo, eso puede tardar unos milisegundos y pasar
completamente desapercibido en desarrollo.
Por qué esto es especialmente peligroso en AEM as a Cloud Service
El mismo comportamiento que es invisible en local se vuelve un problema real contra volumen de contenido de producción. Cuantas más páginas, assets o nodos tenga el subárbol que recorres, más caro es el traversal — y ese coste no es solo “más lento”: Oak impone un límite de lectura por defecto. Cuando una query lee o recorre más de 100.000 nodos, se detiene y lanza una excepción:
The query read or traversed more than 100000 nodes.
To avoid affecting other tasks, processing was stopped.
Existe un límite equivalente para el ordenado en memoria de resultados
(cuando el ORDER BY no puede resolverse mediante un índice), con un
mensaje similar al superar los 500.000 nodos leídos en memoria. Estos
límites llevan activados por defecto desde AEM 6.3 precisamente para
evitar que una query mal indexada consuma recursos del repositorio de
forma descontrolada y afecte a otras tareas concurrentes (otras queries,
replicación, indexación asíncrona).
Esto tiene una consecuencia muy concreta para un desarrollador AEM: una query que “funciona” contra un author local con contenido de prueba puede fallar directamente —no solo ir lenta— en cuanto se despliega contra el volumen real de un sitio en producción. Y en AEM as a Cloud Service, donde no tienes acceso a nivel de sistema para simplemente subir ese límite como parche rápido, la respuesta soportada no es tocar el umbral: es indexar o acotar la query. La propia documentación de mejores prácticas de query e indexación de AEM as a Cloud Service insiste en que toda query debe explicarse antes de salir a producción y no debe contener una traversal en su plan de ejecución.
Cómo comprobar si tu query usa un índice
AEM expone una herramienta de Query Performance (también conocida como
“Explain Query”) dentro del dashboard de Operaciones, en
/libs/granite/operations/content/diagnosistools/queryPerformance.html. En
AEM as a Cloud Service se accede a la vista equivalente a través del
Developer Console de Cloud Manager. Ahí puedes pegar tanto XPath como
JCR-SQL2 (o el resultado de un QueryBuilder) y obtener el plan de
ejecución real:
- Si el plan contiene algo como
/* traverse "cq:Page" */, la query no está usando ningún índice: es una traversal completa del subárbol. - Si el plan nombra un índice —por ejemplo algo del tipo
/* lucene:cqPageLucene(/oak:index/cqPageLucene) ... */— la query se está resolviendo mediante ese índice.
La herramienta también puntúa las queries por “Read Optimization” (la
proporción entre nodos escaneados y nodos que realmente coinciden con el
resultado): una query bien indexada suele puntuar en torno al 90% o más;
una puntuación baja indica que, aunque técnicamente use un índice, sigue
leyendo muchos más nodos de los necesarios (por ejemplo porque el
ORDER BY no está cubierto por el índice y Oak tiene que ordenar en
memoria).
Para queries construidas con QueryBuilder en concreto, en un entorno de
desarrollo local puedes usar la consola de depuración de QueryBuilder en
/libs/cq/search/content/querydebug.html, que permite lanzar un conjunto
de predicados y ver tanto los resultados como el plan resultante antes de
escribir ese mismo PredicateGroup en código Java.
Cuándo un índice de Oak personalizado está justificado
No toda query lenta necesita un índice nuevo. AEM ya trae índices Lucene
para los casos más comunes —páginas (cqPageLucene), assets
(damAssetLucene), tags, tipo de nodo— que cubren la mayoría de búsquedas
por sling:resourceType, plantilla, jcr:primaryType o rutas de
etiquetas. Antes de crear un índice propio, comprueba con la herramienta
de Explain Query si alguno de los índices existentes ya puede cubrir la
combinación de restricciones que necesitas: un índice redundante no
mejora nada y añade coste de indexación en cada escritura al repositorio.
Un índice personalizado está justificado cuando filtras o ordenas por
propiedades específicas de tu proyecto que ningún índice out-of-the-box
cubre —por ejemplo, una combinación de metadatos custom de DAM, o un
ORDER BY sobre una propiedad de negocio que no forma parte de los
índices estándar—. En un proyecto AEM, la definición del índice se
despliega como contenido inmutable dentro de un paquete (normalmente
ui.apps o un módulo dedicado a índices), apuntando al nodo
/oak:index/<nombre>:
<!-- 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>
En AEM as a Cloud Service no existe CRXDE Lite en producción para crear o tocar el índice a mano: la definición viaja con el código, pasa por el pipeline de Cloud Manager y debe validarse contra un entorno de staging con volumen de contenido representativo antes de llegar a producción, precisamente porque el comportamiento de una query frente a 200 páginas de prueba y frente a 200.000 páginas reales puede ser radicalmente distinto.
Dónde aparece esto en un proyecto AEM real
- La búsqueda de autor va lenta al escalar: un buscador de touch UI o
un
granite:searchque iba bien en QA empieza a arrastrarse (o a fallar directamente) al desplegarse contra el volumen real de un DAM o de un árbol de contenido en producción; casi siempre se debe a un predicado añadido tarde que Oak no puede resolver con los índices existentes. - Un job programado que “siempre había funcionado”: un
Scheduler/Runnableo un script de migración que ejecuta un JCR-SQL2 amplio (por ejemplo, “encuentra todas las páginas que todavía usan el componente X”) puede funcionar sin problema en local y empezar a fallar con la excepción de traversal en Cloud Service en cuanto se ejecuta contra contenido real. - Antes de asumir que es “un bug de AEM”: reproduce la query fallida en la herramienta de Query Performance / Explain Query contra un entorno con volumen representativo. Casi siempre el plan revela una traversal o un índice mal aprovechado, y la solución está en acotar la query o añadir el índice adecuado, no en subir límites.