AEM Guide

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.

jcroakquerybuilderjcr-sql2performance

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:

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