Módulo 6: Catalyst Explain And Caching

Leyendo `.explain()` en sus distintos modos

Descripción

Desde el módulo 2, cada .explain() de esta guía usó su forma más simple: sin argumentos, mostrando solo el plan físico final. Eso fue suficiente para leer si una operación disparaba un shuffle, o si un join elegía BroadcastHashJoin. Pero .explain() acepta un parámetro mode con cinco valores distintos —simple, extended, cost, formatted, codegen—, y cada uno responde una pregunta diferente sobre la misma consulta. Esta lección corre los cinco, uno por uno, sobre exactamente el mismo join y agregación que ya conoces —esta vez sobre fact_orders_at_scale completo, diez millones de filas—, y muestra qué información nueva aporta cada modo que los otros no tienen.

Conexión con el módulo. La lección 2 ya usó mode="extended" para leer las cuatro fases de Catalyst. Esta lección completa el catálogo: el modo por defecto que ya conocías sin saber su nombre (simple), el que agrega estimaciones de tamaño (cost), el que numera cada operador para plan grandes (formatted), y el que confirma si Spark compiló código real para tu consulta (codegen).

Una analogía: el mismo mapa, con cinco niveles de detalle

Un GPS moderno no te muestra siempre el mismo nivel de detalle. A veces solo quieres la ruta general, sin distracciones —el equivalente de simple—. A veces quieres ver el razonamiento completo: por qué el GPS descartó una calle, qué reglas de tránsito consideró —el equivalente de extended, con las cuatro fases—. A veces quieres saber cuánto tráfico estimado hay en cada tramo, antes de salir —cost, con sus estimaciones de tamaño—. A veces necesitas una lista numerada, paso por paso, para seguirla con precisión en un mapa muy grande y complejo —formatted—. Y, muy ocasionalmente, quieres confirmar que el navegador realmente compiló las instrucciones en un programa eficiente para tu vehículo específico, en vez de improvisar cada giro sobre la marcha —codegen—. Ninguno de los cinco es "el correcto"; cada uno sirve para una pregunta distinta.

Ejemplo trabajado, parte 1: la misma consulta del módulo 5, en cuatro modos

# explain_modes.py
from pyspark.sql import SparkSession
from pyspark.sql import functions as F
from pyspark.sql.types import (
    StructType, StructField, StringType, IntegerType, DoubleType, TimestampType,
)

spark = SparkSession.builder.appName("kiosko-spark").master("local[*]").getOrCreate()

scale_schema = StructType([
    StructField("order_id", StringType(), False),
    StructField("franchise_id", IntegerType(), False),
    StructField("store_id", StringType(), False),
    StructField("product_id", StringType(), False),
    StructField("quantity", IntegerType(), False),
    StructField("unit_price", DoubleType(), False),
    StructField("order_ts", TimestampType(), False),
])
dim_store_schema = StructType([
    StructField("store_id", StringType(), False),
    StructField("store_name", StringType(), False),
    StructField("city", StringType(), False),
])
orders_at_scale_df = spark.read.csv(
    "kiosko_orders_at_scale.csv", schema=scale_schema, header=True, enforceSchema=False,
)
dim_store_df = spark.read.csv("dim_store.csv", schema=dim_store_schema, header=True, enforceSchema=False)

fact_orders_at_scale_df = orders_at_scale_df.withColumn("revenue", F.col("quantity") * F.col("unit_price"))
q = (
    fact_orders_at_scale_df
    .join(dim_store_df, "store_id")
    .groupBy("store_id")
    .agg(F.sum("revenue").alias("total_revenue"))
)

for mode in ["simple", "extended", "cost", "formatted", "codegen"]:
    print(f"\n\n######## MODE = {mode} ########")
    q.explain(mode=mode)

Qué esperar — mode="simple" (el que ya usaste desde el módulo 2, sin saber su nombre). Al correr python3 explain_modes.py, la primera sección es exactamente esta (ejecutada en esta corrida, sobre fact_orders_at_scale completo):

######## MODE = simple ########
== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=false
+- HashAggregate(keys=[store_id#2], functions=[sum(revenue#11)])
   +- Exchange hashpartitioning(store_id#2, 200), ENSURE_REQUIREMENTS, [plan_id=40]
      +- HashAggregate(keys=[store_id#2], functions=[partial_sum(revenue#11)])
         +- Project [store_id#2, revenue#11]
            +- BroadcastHashJoin [store_id#2], [store_id#7], Inner, BuildRight, false, false
               :- Project [store_id#2, (cast(quantity#4 as double) * unit_price#5) AS revenue#11]
               :  +- Filter isnotnull(store_id#2)
               :     +- FileScan csv [store_id#2,quantity#4,unit_price#5] Batched: false, ...
               +- BroadcastExchange HashedRelationBroadcastMode(List(input[0, string, false]),false), [plan_id=35]
                  +- Filter isnotnull(store_id#7)
                     +- FileScan csv [store_id#7] Batched: false, ...

simple es el valor por defecto —q.explain() sin argumentos produce exactamente esto—: solo el plan físico final, sin las fases lógicas ni ninguna estimación de tamaño. Es el modo correcto cuando la única pregunta es "¿qué estrategia eligió Spark?" — la pregunta que respondieron los módulos 4 y 5 en cada una de sus lecciones.

mode="extended". Produce exactamente las cuatro fases que ya leíste en la lección 2 de este módulo —Parsed, Analyzed, Optimized, Physical—, ahora sobre diez millones de filas en vez de cuarenta. La estructura del plan es idéntica a la de la lección 2 porque el tamaño de los datos no cambia la forma del plan lógico ni las reglas de optimización que se aplican — cambia, en cambio, las estimaciones de tamaño, que es exactamente lo que el siguiente modo agrega.

Qué esperar — mode="cost".

######## MODE = cost ########
== Optimized Logical Plan ==
Aggregate [store_id#2], [store_id#2, sum(revenue#11) AS total_revenue#13], Statistics(sizeInBytes=5.8 GiB)
+- Project [store_id#2, revenue#11], Statistics(sizeInBytes=5.8 GiB)
   +- Join Inner, (store_id#2 = store_id#7), Statistics(sizeInBytes=9.0 GiB)
      :- Project [store_id#2, (cast(quantity#4 as double) * unit_price#5) AS revenue#11], Statistics(sizeInBytes=224.4 MiB)
      :  +- Filter isnotnull(store_id#2), Statistics(sizeInBytes=573.4 MiB)
      :     +- Relation [order_id#0,franchise_id#1,store_id#2,product_id#3,quantity#4,unit_price#5,order_ts#6] csv, Statistics(sizeInBytes=573.4 MiB)
      +- Project [store_id#7], Statistics(sizeInBytes=41.0 B)
         +- Filter isnotnull(store_id#7), Statistics(sizeInBytes=100.0 B)
            +- Relation [store_id#7,store_name#8,city#9] csv, Statistics(sizeInBytes=100.0 B)

== Physical Plan ==
AdaptiveSparkPlan isFinalPlan=false
+- HashAggregate(keys=[store_id#2], functions=[sum(revenue#11)], output=[store_id#2, total_revenue#13])
   +- Exchange hashpartitioning(store_id#2, 200), ENSURE_REQUIREMENTS, [plan_id=40]
      ... (identico al plan fisico de mode="simple")

Este es el modo que responde una pregunta que los módulos 4 y 5 dejaron implícita: ¿de dónde sale el número que decide si una tabla califica para BroadcastHashJoin? Fíjate en la línea del Relation [store_id#7,store_name#8,city#9] csv, Statistics(sizeInBytes=100.0 B) — esa es la estimación de tamaño de dim_store.csv, la misma cifra (en bytes) que el módulo 5 citó al explicar por qué calificaba para broadcast, ahora visible directamente en el plan, sin tener que consultarla aparte. Y fíjate en el contraste: el lado de fact_orders_at_scale estima 9.0 GiB para el resultado del Join — muy por encima del umbral de broadcast (10 MB por defecto) — así que ese lado nunca podría ser el que se copia; solo dim_store, con sus 100.0 B, califica. cost es el único modo que muestra estos números explícitamente.

Qué esperar — mode="formatted".

######## MODE = formatted ########
== Physical Plan ==
AdaptiveSparkPlan (12)
+- HashAggregate (11)
   +- Exchange (10)
      +- HashAggregate (9)
         +- Project (8)
            +- BroadcastHashJoin Inner BuildRight (7)
               :- Project (3)
               :  +- Filter (2)
               :     +- Scan csv  (1)
               +- BroadcastExchange (6)
                  +- Filter (5)
                     +- Scan csv  (4)


(1) Scan csv 
Output [3]: [store_id#2, quantity#4, unit_price#5]
Batched: false
Location: InMemoryFileIndex [file:/.../kiosko_orders_at_scale.csv]
PushedFilters: [IsNotNull(store_id)]
ReadSchema: struct<store_id:string,quantity:int,unit_price:double>

(2) Filter
Input [3]: [store_id#2, quantity#4, unit_price#5]
Condition : isnotnull(store_id#2)

(3) Project
Output [2]: [store_id#2, (cast(quantity#4 as double) * unit_price#5) AS revenue#11]
Input [3]: [store_id#2, quantity#4, unit_price#5]

(4) Scan csv 
Output [1]: [store_id#7]
Batched: false
Location: InMemoryFileIndex [file:/.../dim_store.csv]
PushedFilters: [IsNotNull(store_id)]
ReadSchema: struct<store_id:string>

(5) Filter
Input [1]: [store_id#7]
Condition : isnotnull(store_id#7)

(6) BroadcastExchange
Input [1]: [store_id#7]
Arguments: HashedRelationBroadcastMode(List(input[0, string, false]),false), [plan_id=35]

(7) BroadcastHashJoin
Left keys [1]: [store_id#2]
Right keys [1]: [store_id#7]
Join type: Inner
Join condition: None

(8) Project
Output [2]: [store_id#2, revenue#11]
Input [3]: [store_id#2, revenue#11, store_id#7]

(9) HashAggregate
Input [2]: [store_id#2, revenue#11]
Keys [1]: [store_id#2]
Functions [1]: [partial_sum(revenue#11)]
Aggregate Attributes [1]: [sum#25]
Results [2]: [store_id#2, sum#26]

(10) Exchange
Input [2]: [store_id#2, sum#26]
Arguments: hashpartitioning(store_id#2, 200), ENSURE_REQUIREMENTS, [plan_id=40]

(11) HashAggregate
Input [2]: [store_id#2, sum#26]
Keys [1]: [store_id#2]
Functions [1]: [sum(revenue#11)]
Aggregate Attributes [1]: [sum(revenue#11)#24]
Results [2]: [store_id#2, sum(revenue#11)#24 AS total_revenue#13]

(12) AdaptiveSparkPlan
Output [2]: [store_id#2, total_revenue#13]
Arguments: isFinalPlan=false

formatted reorganiza exactamente la misma información de simple, pero de dos formas que se vuelven valiosas en planes mucho más grandes que este: primero, cada operador tiene un número ((1) a (12)) que aparece tanto en el árbol de arriba como en su bloque de detalle más abajo — así puedes ubicar de un vistazo dónde vive cada pieza sin contar niveles de indentación. Segundo, cada bloque de detalle separa explícitamente Input (lo que ese operador recibe) de Output/Arguments (lo que produce o cómo está configurado) — información que en simple está comprimida en una sola línea densa. Para el plan corto de esta lección, la diferencia es cosmética; para un plan de veinte o treinta operadores —algo común en un pipeline de producción con varios joins y agregaciones encadenadas—, formatted es mucho más fácil de navegar.

Ejemplo trabajado, parte 2: codegen, antes y después de ejecutar

Qué esperar — mode="codegen", sobre un DataFrame que todavía no se ejecutó.

######## MODE = codegen ########
Found 0 WholeStageCodegen subtrees.

Esto sorprende a cualquiera que espere ver código Java de inmediato: 0 subtrees, sobre exactamente la misma consulta que en simple ya mostraba un plan físico completo. La razón está conectada con algo que ya viste en la lección 2: el plan físico está envuelto en AdaptiveSparkPlan isFinalPlan=false — todavía no es definitivo. Whole-stage code generation compila el plan final, después de que Adaptive Query Execution termina de decidir su forma real; antes de eso, no hay nada compilado todavía que mostrar.

# codegen_after_action.py -- continuacion del script anterior, mismo objeto q
rows = q.collect()
print("resultado:", rows)

print("\n######## MODE = codegen, DESPUES de collect() ########")
q.explain(mode="codegen")

Qué esperar (ejecutado en esta corrida, sobre el mismo objeto q, después de .collect()):

resultado: [Row(store_id='S02', total_revenue=9699999.999998083), Row(store_id='S01', total_revenue=9574999.999993788), Row(store_id='S03', total_revenue=7262500.000003301)]

######## MODE = codegen, DESPUES de collect() ########
Found 3 WholeStageCodegen subtrees.
== Subtree 1 / 3 (maxMethodCodeSize:131; maxConstantPoolSize:108(0.16% used); numInnerClasses:0) ==
*(1) Filter isnotnull(store_id#7)
+- FileScan csv [store_id#7] Batched: false, ...

Generated code:
/* 001 */ public Object generate(Object[] references) {
/* 002 */   return new GeneratedIteratorForCodegenStage1(references);
/* 003 */ }
... (bytecode Java generado, omitido por extension -- confirmado real, no un resumen)

== Subtree 2 / 3 (maxMethodCodeSize:359; maxConstantPoolSize:327(0.50% used); numInnerClasses:2) ==
*(2) HashAggregate(keys=[store_id#2], functions=[partial_sum(revenue#11)], output=[store_id#2, sum#26])
+- *(2) Project [store_id#2, revenue#11]
   +- *(2) BroadcastHashJoin [store_id#2], [store_id#7], Inner, BuildRight, false, false
      :- *(2) Project [store_id#2, (cast(quantity#4 as double) * unit_price#5) AS revenue#11]
      :  +- *(2) Filter isnotnull(store_id#2)
      :     +- FileScan csv [store_id#2,quantity#4,unit_price#5] Batched: false, ...
      +- BroadcastQueryStage 0
         +- BroadcastExchange HashedRelationBroadcastMode(List(input[0, string, false]),false), [plan_id=62]
            +- *(1) Filter isnotnull(store_id#7)
               +- FileScan csv [store_id#7] Batched: false, ...

== Subtree 3 / 3 (maxMethodCodeSize:196; maxConstantPoolSize:236(0.36% used); numInnerClasses:0) ==
*(3) HashAggregate(keys=[store_id#2], functions=[sum(revenue#11)], output=[store_id#2, total_revenue#13])
+- AQEShuffleRead coalesced
   +- ShuffleQueryStage 1
      +- Exchange hashpartitioning(store_id#2, 200), ENSURE_REQUIREMENTS, [plan_id=109]
         +- *(2) HashAggregate(keys=[store_id#2], functions=[partial_sum(revenue#11)], output=[store_id#2, sum#26])
            +- *(2) Project [store_id#2, revenue#11]
               +- *(2) BroadcastHashJoin [store_id#2], [store_id#7], Inner, BuildRight, false, false
                  ...

Ahora sí hay tres subárboles compilados. Fíjate en el asterisco (*(1), *(2), *(3)) que precede a varios operadores dentro de cada subtree — esa es la marca visible de qué operadores quedaron fusionados en la misma pieza de código: el Subtree 2 fusiona un Filter, un Project y un BroadcastHashJoin en una sola clase Java compilada (*(2) en los tres), en vez de tres pasos interpretados por separado. El BroadcastExchange y el AQEShuffleRead, en cambio, no llevan asterisco — son puntos donde el flujo de datos cruza una frontera (una escritura de shuffle, una lectura de un resultado ya materializado) que whole-stage codegen no puede fusionar hacia atrás. También fíjate en el sum(revenue) sin redondear del resultado (9699999.999998083 en vez de 9700000.0) — el mismo fenómeno de punto flotante que ya documentó el módulo 4 sobre diez millones de sumas; redondeado con F.round(F.sum("revenue"), 2) da exactamente 9700000.0, como confirmaron los módulos anteriores.

Diagrama: qué pregunta responde cada modo

flowchart TD
    Q["Misma consulta:\njoin + groupBy + sum"] --> S["simple\n¿que estrategia eligio Spark?"]
    Q --> E["extended\n¿por que fases paso el plan?"]
    Q --> C["cost\n¿que tamanos estimo Catalyst?"]
    Q --> F["formatted\n¿como leo un plan grande, operador por operador?"]
    Q --> G["codegen\n¿se compilo codigo real, y donde se fusionaron los operadores?"]

    style S fill:#69c,stroke:#333
    style E fill:#9c6,stroke:#333
    style C fill:#fc6,stroke:#333
    style F fill:#c9c,stroke:#333
    style G fill:#f96,stroke:#333

Errores comunes

Usar mode="cost" esperando ver bytes reales, medidos durante la ejecución. Qué pasa: alguien ve Statistics(sizeInBytes=5.8 GiB) en el Optimized Logical Plan y lo interpreta como una medición real, hecha después de ejecutar la consulta. Por qué pasa: la palabra "Statistics" suena a un dato medido, no a una predicción. Cómo detectarlo: si tu explicación de estas cifras no incluye la palabra "estimado" o "estimación" en ningún momento, revisa de nuevo — estas cifras aparecen sobre el Optimized Logical Plan, que es una fase anterior a cualquier ejecución real (lección 2 de este módulo). Cómo corregirlo: cost muestra estimaciones basadas en el tamaño de los archivos de origen y reglas de propagación simples —no en datos reales todavía—; para bytes medidos de verdad, después de ejecutar, la fuente correcta es la Spark UI (o su API REST), como ya viste en el módulo 4, lección 5.

Esperar que mode="codegen" muestre subtrees antes de cualquier acción, igual que simple muestra el plan físico completo de inmediato. Qué pasa: alguien corre .explain(mode="codegen") sobre un DataFrame recién construido, ve Found 0 WholeStageCodegen subtrees, y concluye que algo falló. Por qué pasa: los otros cuatro modos (simple, extended, cost, formatted) sí muestran contenido completo sin necesitar ninguna acción previa, así que es razonable esperar el mismo comportamiento de codegen. Cómo detectarlo: si ves Found 0 WholeStageCodegen subtrees y no has llamado ninguna acción (.collect(), .count(), .show()) sobre ese mismo objeto todavía, ese es exactamente el comportamiento esperado, no un error. Cómo corregirlo: codegen describe el código ya compilado del plan final —el que Adaptive Query Execution termina de fijar después de ejecutar—; llama una acción primero, sobre el mismo objeto DataFrame, y vuelve a llamar .explain(mode="codegen") sobre ese mismo objeto para ver los subtrees reales.

Confundir el asterisco (*(N)) de codegen con el número de operador de formatted. Qué pasa: alguien ve *(2) en la salida de codegen y (9) en la salida de formatted, y asume que son el mismo tipo de numeración, o que se pueden comparar directamente entre sí. Por qué pasa: ambos son números entre paréntesis, cerca de nombres de operadores, en dos modos distintos de la misma familia de comandos. Cómo detectarlo: si intentas hacer corresponder el número (9) de formatted con algún *(9) de codegen, no vas a encontrar una relación directa — son sistemas de numeración completamente independientes. Cómo corregirlo: en formatted, el número identifica la posición de un operador dentro del árbol completo del plan, en orden de aparición. En codegen, el número dentro del asterisco identifica a qué subtree compilado pertenece ese operador —todos los operadores con el mismo número de asterisco se fusionaron en la misma pieza de código Java—; operadores de subtrees distintos, como el BroadcastExchange, no tienen asterisco porque no pertenecen a ningún subtree fusionado.

Ejercicios

Ejercicio 1 — Calcula, a mano, si el resultado del Join (9.0 GiB estimados) califica para broadcast. Usando el umbral por defecto de spark.sql.autoBroadcastJoinThreshold (10 MB, ya citado en el módulo 5), compara ese valor contra 9.0 GiB (el resultado del Join, según mode="cost") y contra 100.0 B (dim_store.csv, según el mismo modo). Confirma cuál de los dos, si acaso alguno, calificaría.

Ver solución
threshold_mb = 10
join_result_gib = 9.0
dim_store_bytes = 100.0

threshold_bytes = threshold_mb * 1024 * 1024
join_result_bytes = join_result_gib * 1024 * 1024 * 1024

print(f"umbral de broadcast = {threshold_bytes:,.0f} bytes")
print(f"resultado del Join estimado = {join_result_bytes:,.0f} bytes -- califica: {join_result_bytes < threshold_bytes}")
print(f"dim_store.csv = {dim_store_bytes:,.0f} bytes -- califica: {dim_store_bytes < threshold_bytes}")

Salida esperada:

umbral de broadcast = 10,485,760 bytes
resultado del Join estimado = 9,663,676,416 bytes -- califica: False
dim_store.csv = 100 bytes -- califica: True

Confirmado: el resultado del Join (9.0 GiB) está muy por encima del umbral —nunca podría ser el lado que se copia—, mientras que dim_store.csv (100 bytes) está muy por debajo. Esto confirma, con las cifras exactas que mode="cost" expone, la misma decisión que el plan físico ya mostraba con BroadcastExchange sobre el lado de dim_store.

Ejercicio 2 — Cuenta cuántos operadores llevan asterisco en el Subtree 2 del ejemplo trabajado, y a cuáles NO se les asigna ninguno. Revisando el texto ya mostrado del Subtree 2 / 3, cuenta cuántos operadores tienen la marca *(2) delante, y nombra los dos operadores de ese mismo bloque que no llevan ningún asterisco.

Ver solución

Con marca *(2): HashAggregate, Project, BroadcastHashJoin, el segundo Project (dentro del lado izquierdo del join) y el Filtercinco operadores fusionados en la misma clase compilada. Sin ningún asterisco: FileScan (la lectura misma del archivo no se fusiona con el procesamiento posterior) y BroadcastExchange/BroadcastQueryStage (el punto donde el resultado ya materializado de dim_store entra al subtree, una frontera que whole-stage codegen no cruza hacia atrás). El patrón general: la lectura de datos y los puntos donde un resultado ya materializado (un shuffle, un broadcast) entra al plan quedan fuera de la fusión — solo el procesamiento fila-por-fila entre esas fronteras se compila junto.

Ejercicio 3 — Explica, sin código, por qué mode="formatted" es más útil que mode="simple" en un plan de treinta operadores, aunque ambos muestren la misma información. En 2-3 frases, justifica por qué la numeración y la separación en bloques de formatted importan más a medida que un plan crece, incluso si el contenido informativo es idéntico al de simple.

Ver solución

En simple, toda la información de cada operador —columnas de entrada, condición, argumentos— vive comprimida en una sola línea de texto, y la única forma de saber a qué nivel del árbol pertenece un operador es contar la indentación (los símbolos +- y :) de izquierda a derecha. Con treinta operadores, esa indentación se vuelve difícil de seguir visualmente, y las líneas largas se cortan o se envuelven de forma confusa. formatted separa el árbol resumido (solo nombres y números) del detalle completo de cada operador, así que puedes primero ubicar, en el árbol corto, cuál operador te interesa por su número, y después saltar directo a su bloque de detalle sin tener que leer treinta líneas densas de corrido — la misma razón por la que un índice al principio de un libro largo es más útil que en un libro de tres páginas.

Resumen y siguiente paso

Esta lección corrió .explain() en sus cinco modos —simple, extended, cost, formatted, codegen— sobre exactamente la misma consulta, y mostró qué pregunta distinta responde cada uno: simple confirma la estrategia elegida (lo que ya usaste desde el módulo 2); extended muestra las cuatro fases lógicas y física (la lección 2 de este módulo); cost expone las estimaciones de tamaño detrás de decisiones como el broadcast join; formatted numera cada operador para navegar planes grandes; y codegen confirma qué operadores se fusionaron en código Java real —solo disponible después de que el plan alcanza su forma final, tras una acción real.

Antes de avanzar deberías poder: elegir el modo correcto de .explain() según la pregunta que quieras responder; explicar por qué mode="codegen" muestra 0 subtrees antes de cualquier acción; y leer las estimaciones de mode="cost" sabiendo que son predicciones, no mediciones.

La lección 4 completa la historia de isFinalPlan=false: qué es exactamente Adaptive Query Execution, qué partes del plan puede cambiar después de ejecutar, y cómo leer esa diferencia con evidencia real.

Recursos

  • Apache Spark — SQL Performance Tuning (la referencia oficial de los cinco modos de .explain(), con la sintaxis exacta explain(mode="...") — base completa de esta lección). spark.apache.org/docs/latest/sql-performance-tuning.html. En inglés.
  • Apache Spark — Monitoring and Instrumentation (la API REST de la Spark UI para métricas reales de ejecución, distintas de las estimaciones de mode="cost" — ya citada en el módulo 4). spark.apache.org/docs/latest/monitoring.html. En inglés.
  • DISEÑO de esta guía (spark-and-distributed-processing-guide/DISENO.md) — la especificación exacta de esta lección: .explain(mode="formatted") y los demás modos, sobre la consulta del módulo 5 leída a escala.