⚙️ PIEZA · COMPUTE — dónde se ejecuta el SQL
| Versión | v1.0 — las cinco capas trazadas |
| Estado | ⚠️ Un nodo, al 88 % de CPU |
| Última medición | 2026-08-12 |
Trazada por capas: 1 sustrato · 2 registro · 3 elección · 4 el puente · 5 el
protocolo.Y COMPUTE no es Junction. Junction elige y despacha; COMPUTE es lo que hay
al otro lado. Junction tendrá su propia pieza.
Qué es
Un único cluster de Kubernetes en
europe-west1con Spark encima, y un puente
HTTP delante porque Spark Connect no habla con Node.
Qué NO es
- No es la ingesta. La ingesta es Node + PyIceberg y no toca este cluster (INGESTION).
- No es plural, hoy. Hay un registro de motores, pero elegible es uno.
- No está expuesto. Ningún
Servicetiene IP externa: todo esClusterIP.
Capa 1 · El sustrato físico, medido
El nodo — uno
n2-standard-4 · 4 vCPU · 16 GiB · GKE v1.35.6
pool: main-pool-4 (autoescalado 1→4) ⛔ techo real: CPUS_ALL_REGIONS = 12
⚠️ Ocupación actual: 88 % de CPU y 87 % de memoria reservadas. Quedan ~500m y ~2 GiB. El límite agregado va al 432 % de la capacidad: está sobrecomprometido a propósito —los picos se solapan— pero eso significa que una sola pieza más obliga a un segundo nodo.
(La subida de 82 %→88 % es el IRC de Gravitino que entró hoy — mapa §8·quater.)
Qué corre dentro
| Pod | Imagen | CPU req | Mem req |
|---|---|---|---|
carbon-connect-server-0 | spark-k8s:4.1.2-stackable26.7.0 | 500m | 3 Gi |
spark-connect-server-…-exec-1 | idem | 300m | 2 Gi |
spark-connect-server-…-exec-2 | idem | 300m | 2 Gi |
spark-bridge | python:3.12-slim | 250m | 768 Mi |
gravitino-irc | apache/gravitino-iceberg-rest:1.2.0 | 250m | 1 Gi |
⭐ El puente es un python:3.12-slim de 250m. No es un motor: es un traductor de
protocolo, y su tamaño lo dice.
Cómo está declarado
Un solo CR, gestionado por el operador de Stackable:
sparkconnectserver.spark.stackable.tech/carbon-connect (3 días de vida)
productVersion: 4.1.2
server.cpu.max: 2 · executor.cpu.max: 1
clusterOperation.stopped: false · reconciliationPaused: false
Operadores: spark-k8s · commons · listener · secret, todos 26.7.0.
⚠️ executor.replicas no está declarado — los 2 ejecutores que corren salen del
default del operador, no de una decisión nuestra escrita.
El perímetro de red
carbon-connect-server ClusterIP ← Spark Connect (gRPC)
carbon-connect-server-headless ClusterIP
carbon-connect-server-metrics ClusterIP
spark-bridge ClusterIP ← el puente HTTP
gravitino-irc ClusterIP
✅ Nada tiene IP externa. El cómputo no es alcanzable desde internet; se entra
por el puente, y al puente sólo desde dentro del cluster (o por port-forward).
Lo que ya NO está
El namespace trino existe y está vacío. Trino salió —era la decisión de
2026-08-09 (Spark = cómputo · StarRocks = serving)— y con él el pool bajó de 3 nodos a
- Esa salida es el presupuesto de todo lo que entre ahora.
Lo que esta capa ya deja ver
| ⚠️ | 88 % de CPU reservada en un solo nodo. No hay margen para una pieza más sin un segundo nodo — y el techo de cuota es 12 vCPU |
| ⚠️ | Los 2 ejecutores son un default, no una decisión. executor.replicas no está en el CR |
| ⚠️ | Un solo carbon-connect-server, sin réplica. Si cae, no hay cómputo — y el puente también es 1 réplica |
| ✅ | Nada expuesto a internet, y las versiones de operador y producto son coherentes (26.7.0 / 4.1.2) |
Capa 2 · El registro de motores
lib/compute/engines/ — cinco adaptadores y un mapa TOTAL:
const REGISTRY: Record<Engine, EngineAdapter> = { spark, duckdb, karma, mlrunner, pg }
⭐ Que sea Record<Engine, …> es la decisión: si alguien añade un motor al tipo y
no su adaptador, rompe en tsc. El registry no puede quedarse corto en
silencio. Y por eso quién puede hacer qué se consulta al registry, no a una
cadena de if esparcida por los call-sites.
Lo que declara cada uno — leído del runtime de producción
curl -H "x-diag-key: $SPARK_BRIDGE_JWT_SECRET" https://app.paladio.io/api/diag/motor
| Motor | available | sql | write | dialecto | capacidades |
|---|---|---|---|---|---|
| ⭐ spark | ✅ | ✅ | ✅ | spark | 16 |
| duckdb | ✅ | ✅ | ✅ | duckdb | 25 |
| karma | ✅ | ✅ | ⛔ | substrait | 0 |
| mlrunner | ✅ | ⛔ | ✅ | — | 0 |
| pg | ✅ | ⛔ | ✅ | — | 0 |
selección efectiva: lectura → spark · escritura → spark
⇒ Cinco declarados · cinco disponibles · tres hablan SQL · uno elegido para todo.
⚠️ Qué significa available, que no es lo que parece
En los cuatro adaptadores es el mismo predicado: «¿tiene su URL configurada?»
spark → Boolean(SPARK_BRIDGE_URL)
duckdb → Boolean(DUCK_SERVER_URL)
karma → Boolean(KARMA_SQL_URL)
mlrunner → Boolean(ML_RUNNER_URL && ML_RUNNER_TOKEN)
available: truesignifica «está CABLEADO», no «responde».
Y el propio adaptador de Spark lleva escrita la lección que lo explica: «Trino dejó
la lección del fallo contrario: llegó a anunciar available: true…». ⇒ el registro
declara intención de configuración, no salud. La salud es otra medición, y no la
da este endpoint.
Dos cosas que el censo destapa
⭐ Karma está CABLEADO en producción. Su comentario dice que nace
available: false «mientras la env no exista» — y KARMA_SQL_URL existe. Está
enchufado, declara 0 capacidades y dialecto substrait, y no se elige nunca.
Es un motor conectado y sin tráfico.
⚠️ El motor elegido declara MENOS capacidades medidas que el que no se elige:
spark 16 · duckdb 25. No es una contradicción —la elección de 2026-08-09 fue
estructural (Spark es el cómputo; DuckDB no tiene cliente para lo que necesitamos)—
pero conviene tenerlo escrito: se eligió por arquitectura, no por capacidad
medida, y ese saldo hay que revisarlo cuando cambie alguna de las dos.
Capa 3 · La elección — la función que no elige
export function elegirMotorDeLectura(esWrite: boolean) {
if (esWrite) return getSqlEngine('spark');
return getSqlEngine('spark'); // ← las DOS ramas, el mismo motor
}
⇒ Con cinco motores cableados, la elección tiene un solo destino. El nombre dice «elegir»; lo que hay es un destino único y deliberado, y el porqué está escrito en la propia función.
⛔⛔ Por qué NO hay try { spark } catch { duckdb }
Y son dos razones distintas, una por rama, la segunda más fuerte:
| Rama | Por qué no se degrada |
|---|---|
| Lectura | Un fallback por excepción convierte «el puente está caído» en «la query salió por otro motor con otro dialecto», en silencio. Y no es teórico: timestamp-arithmetic da un resultado DISTINTO sin lanzar (hallazgo de F7). ⇒ degradar de motor es degradar de SEMÁNTICA |
| Escritura | ⭐⭐ DuckDB no pasa por la cara — cero eventos en access_events — así que su commit no se firma, porque la firma la pone la cara. Degradar no daría «el mismo dato por otro camino»: daría dato SIN PROCEDENCIA, en silencio |
⭐⭐⭐ Y la historia que lo justifica, que es la mejor parte
El puente llevaba días vivo, con 8/8 contra el dominio público, y el editor
seguía respondiendo con DuckDB. Como respondía —datos correctos, ni un error—
no había nada que investigar.El síntoma de «Spark no está cableado aquí» y el de «todo va bien» son EL MISMO
BYTE en la respuesta.Tres deploys, un revert de
vercel.jsony una sesión entera buscando en el sitio
equivocado. La causa estaba a unenv ls:SPARK_BRIDGE_*existía en Production y
no en Preview, que es donde se probaba.⇒ La degradación silenciosa no causó el problema: impidió leerlo. Que sale más
caro.
Y el precio se paga a propósito: sin puente no hay lecturas. Un despliegue al que le falte una variable deja el editor caído, no leyendo con otro dialecto. Preferimos el editor caído y ruidoso al editor sano y mentiroso.
El otro extremo: fallar distinguiendo el porqué
getSqlEngine no lanza «el motor petó». Lanza dos errores distintos, y la
diferencia es accionable:
EngineCapabilityError → el motor NO hace SQL (mlrunner, pg)
es un error de SELECCIÓN: reintentar con otro tiene sentido
EngineUnavailableError → sí hace SQL pero NO está cableado aquí
es un error de DESPLIEGUE: reintentar con el mismo, no
⭐ Y un matiz fino: un motor cableable-pero-no-cableado reporta indisponibilidad, no incapacidad — el mensaje dice «falta la env», no «no sabe SQL». Es la misma disciplina de arriba aplicada al error: un veredicto no se calcula por ausencia.
⭐ Tampoco se pre-comprueba capabilities() en elegirMotorDeLectura: sería un
segundo sitio donde decidir lo mismo, «y el día que discrepen ganaría el
equivocado».
Lo que esta capa deja ver
| ⚠️ | La «capa 3» de selección automática está prevista y no existe. El registry declara ser «el punto donde se enchufará», y los dos errores distintos existen precisamente «para que la capa-3 degrade con criterio». Hoy no degrada nadie |
| ⚠️ | availableSqlEngines() sólo lo consume el DIAGNÓSTICO (/api/diag/motor). Existe un inventario de motores aptos que la decisión nunca consulta: hoy sirve para mirar, no para elegir |
| ✅ | Fail-loud coherente en todo el camino: sin motor → excepción con el nombre de la variable que falta, no una respuesta plausible |
Capa 4 · El puente — un servicio con estado, no un traductor
services/spark-bridge/ — 3 ficheros: app.py (307 líneas), sesiones.py,
identidad.py.
Por qué existe
No hay cliente de Spark Connect para Node. Ésa es la razón entera, y es estructural: no es que Spark fuera más rápido, es que desde Node no se le puede hablar. El puente traduce HTTP → gRPC para que Junction pueda despachar.
⭐⭐ Pero traducir no es su trabajo
B0 midió lo único que de verdad manda en el diseño:
primera query: 5.832 ms · p50 en caliente: 125 ms → 47×
Una sesión fría cuesta 47 veces más que una caliente. De ahí que el puente sea
un servicio CON ESTADO: su trabajo no es traducir HTTP a gRPC —eso es trivial—
sino sostener sesiones calientes.
El pool
una sesión por INQUILINO · LRU con tope · caducidad por inactividad
MAX_SESIONES = 8 INACTIVIDAD_S = 1800 (30 min)
estado vivo: {"ok": true, "sesiones_vivas": 1}
⚠️ Por qué hay tope, y no es prudencia genérica: cada sesión consume memoria del driver de Spark, que es un proceso único y el recurso que se agota primero. Sin tope, con muchos inquilinos el driver muere de golpe y sin señal previa.
⭐ Y un detalle de protocolo que costó descubrir: newSession() no existe en
Spark Connect ([JVM_ATTRIBUTE_NOT_SUPPORTED]). La sesión nueva se pide con
.create(); .getOrCreate() reutilizaría la activa — que es exactamente lo que
rompería el aislamiento entre inquilinos.
⭐ El TTL del token OAuth del catálogo se alinea con la inactividad del pool
(token-refresh.idle-timeout = PT1800S), porque «un editor SQL pasa minutos sin
lanzar nada»: si el token caducara antes que la sesión, la sesión seguiría viva y
sin poder leer.
Lo que esta capa deja ver
| ⛔ | La imagen sigue siendo la provisional. python:3.12-slim que hace pip install en cada arranque desde PyPI. Su propio manifiesto lo dice: «vale para el gate de B1 y no vale para producción: cada reinicio depende de PyPI» — y ahí sigue. Una caída de PyPI deja el cómputo sin arrancar |
| ⛔⛔ | 1 réplica, y el pool es estado EN MEMORIA. No es que «no escale»: es que escalar horizontalmente rompería el diseño — dos réplicas serían dos pools, el 47× se pagaría el doble, y la sesión de un inquilino viviría en una réplica y no en la otra |
| ⚠️ | MAX_SESIONES = 8 y hay exactamente 8 inquilinos. El tope está justo en el número de workspaces: el noveno inquilino desaloja al menos activo — funciona, pero es una frontera que nadie ha decidido mirar |
| ⚠️ | El secreto del puente es SIMÉTRICO: «quien lo tenga acuña tokens para CUALQUIER inquilino». Vive en Vercel y en el cluster — y es el mismo que abre /api/diag/motor |
| ✅ | ClusterIP: sin exposición externa, que es lo que hace tolerable lo anterior |
Capa 5 · El protocolo — qué es una sesión, y qué la aísla
La cadena, de punta a punta
Vercel acuña un token corto (sub + workspaceId), firmado con el secreto compartido
│
PUENTE valida firma · caducidad · audiencia ⚠️ NO consulta la base de datos
│ ⭐ y NO REENVÍA el token: Spark se presenta ante la cara con SU credencial
│
SESIÓN de Spark POR INQUILINO, construida con `.create()`
│
la cara /api/iceberg → Index autoriza
Es el patrón de proxy de confianza: «Index es la autoridad para lo que entra por la puerta» — sin duplicar política en dos almacenes.
Qué se le monta a la sesión
spark.sql.catalog.<n> = org.apache.iceberg.spark.SparkCatalog
spark.sql.catalog.<n>.type = rest
spark.sql.catalog.<n>.uri = https://app.paladio.io/api/iceberg ← la CARA
spark.sql.catalog.<n>.warehouse = w_<workspace sin guiones> ← el PREFIJO
spark.sql.catalog.<n>.rest.auth.*= AuthManager de Dremio + OAuth2 contra Keycloak
⭐⭐ El prefijo ES la multi-tenencia. Es el único campo que cambia entre
inquilinos.
⭐⭐⭐ Y el aislamiento tiene DOS líneas, no una
La cara no deduce el inquilino de quién llama: el motor lo DECLARA en el prefijo. Index comprueba (a) que el principal tenga grants ahí y (b) que la tabla pedida no contradiga al prefijo declarado — si lo contradice, niega.
⇒ Aunque al puente lo engañaran para declarar otro inquilino, la comprobación
cruzada de la cara sigue en pie.
Y del lado del puente, el cerrojo es .create(): «con getOrCreate() dos inquilinos
compartirían sesión y catálogo».
⭐⭐ El token que caducaba a los 5 minutos — y por qué no se parcheó
El OAuth2 nativo de Iceberg NO renueva el token: la sesión quedaba inservible a
los 5 minutos (expires_in: 300) y hasta un SHOW NAMESPACES devolvía
NotAuthorizedException — con la sesión aún viva en el LRU, porque el pool mira
inactividad, no validez.
No era configuración nuestra: es el issue apache/iceberg #12363, con este mismo stack (Spark + Lakekeeper + Keycloak + 5 min), cerrado como «not planned». Y el reportante añade el dato que decide: «a similar setup on Trino works correctly» — el hueco es de Spark.
Dos razones más para no parchearlo: su endpoint OAuth2 está deprecado desde Iceberg 1.6 y se elimina en 2.0, y falla con proveedores de identidad externos por no cumplir el estándar — que es exactamente nuestro caso.
⇒ Se usa el AuthManager pluggable (API ≥ 1.9; aquí 1.11.0) con la implementación de Dremio, que refresca en segundo plano.
⛔ Y las dos salidas descartadas, con su motivo: token-exchange en Keycloak
—«arreglaría hoy el mecanismo que Iceberg retira mañana»— y subir el TTL
—«5 min no es el problema: OAuth2 recomienda tokens cortos PORQUE el cliente
renueva», y alargarlos empeora la deuda de secretos por rotar.
Dos detalles de oficio que valen la pena
USE <namespace>nunca propaga su fallo. Si el namespace no existe para ese prefijo, la sesión sigue sirviendo — «un default de comodidad no puede convertirse en una caída» — pero se dice por log, porque una sesión sin él resuelve los nombres pelados de otra manera «y eso no puede ser invisible».- La poda es primero por inactividad y luego por tope, en ese orden: al revés «se podría expulsar una sesión activa mientras se conserva otra muerta».
Lo que esta capa deja ver
| ✅ | El aislamiento es de dos líneas (el prefijo declarado + la comprobación cruzada de la cara), no de una — y ésa es la propiedad más fuerte de todo el cómputo |
| ✅ | El puente no reenvía el token: no hay política duplicada en dos almacenes |
| ⚠️ | El AuthManager es de Dremio, no de Apache: una dependencia de terceros en el camino de autenticación de toda lectura, elegida porque el upstream cerró el issue como not planned |
| ⚠️ | El puente no consulta la base de datos para validar — es rápido y es correcto, pero significa que un token válido con un workspaceId cualquiera crea sesión; lo que impide el daño es la segunda línea, no la primera |
🏁 La pieza, cerrada
Las cinco capas trazadas. Lo que sostiene el cómputo hoy:
1 nodo al 88 % · Spark 4.1.2 sobre Stackable 26.7.0 · todo ClusterIP
5 motores cableados, 3 con SQL, 1 elegido, 0 fallbacks — a propósito
1 puente CON ESTADO (47×), 1 réplica, imagen provisional
aislamiento por prefijo, con doble comprobación
Y las cuatro deudas que la travesía deja ordenadas por daño:
| Deuda | Por qué duele | |
|---|---|---|
| ⛔⛔ | Imagen provisional del puente (pip install desde PyPI en cada arranque) | Una caída de PyPI deja el cómputo entero sin arrancar |
| ⛔ | 1 réplica con estado en memoria | No hay camino de alta disponibilidad sin rediseñar el pool |
| ⚠️ | 88 % de un solo nodo, con techo de 12 vCPU | La siguiente pieza obliga a un segundo nodo |
| ⚠️ | MAX_SESIONES = 8 con 8 inquilinos · executor.replicas sin declarar | Dos fronteras que hoy funcionan por coincidencia, no por decisión |
Historial
| Versión | Fecha | |
|---|---|---|
| v1.0 | 2026-08-12 | Capa 5: el protocolo. El aislamiento tiene dos líneas (prefijo declarado + comprobación cruzada de la cara), y el token que caducaba a los 5 min se resolvió con el AuthManager de Dremio porque el upstream cerró el issue como not planned |
| v0.4 | 2026-08-12 | Capa 4: el puente — con estado a propósito (47× de sesión fría), pool LRU de 8 con 8 inquilinos, y la imagen provisional aún en producción |
| v0.3 | 2026-08-12 | Capa 3: la elección — una función que no elige (spark en las dos ramas) y sin fallback a propósito: degradar de motor es degradar de semántica, y en escritura sería dato sin procedencia |
| v0.2 | 2026-08-12 | Capa 2: el registro. 5 motores, 5 cableados, 3 con SQL, 1 elegido. Y available = cableado, no sano |
| v0.1 | 2026-08-12 | Capa 1: el sustrato físico. Un nodo al 88 %, Spark 4.1.2 sobre Stackable 26.7.0, todo ClusterIP, y Trino ya fuera |