Published

⚙️ PIEZA · COMPUTE — dónde se ejecuta el SQL

Connect any source, model it as an ontology, transform it, and operationalize it, analytics, automation and machine learning, under one governed, self-hostable roof. --- Most teams stitch the...

⚙️ PIEZA · COMPUTE — dónde se ejecuta el SQL

Versiónv1.0las cinco capas trazadas
Estado⚠️ Un nodo, al 88 % de CPU
Última medición2026-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-west1 con 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 Service tiene IP externa: todo es ClusterIP.

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

PodImagenCPU reqMem req
carbon-connect-server-0spark-k8s:4.1.2-stackable26.7.0500m3 Gi
spark-connect-server-…-exec-1idem300m2 Gi
spark-connect-server-…-exec-2idem300m2 Gi
spark-bridgepython:3.12-slim250m768 Mi
gravitino-ircapache/gravitino-iceberg-rest:1.2.0250m1 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

  1. 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
Motoravailablesqlwritedialectocapacidades
sparkspark16
duckdbduckdb25
karmasubstrait0
mlrunner0
pg0
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: true significa «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:

RamaPor qué no se degrada
LecturaUn 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.json y una sesión entera buscando en el sitio
equivocado. La causa estaba a un env 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 NotAuthorizedExceptioncon 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:

DeudaPor 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 memoriaNo hay camino de alta disponibilidad sin rediseñar el pool
⚠️88 % de un solo nodo, con techo de 12 vCPULa siguiente pieza obliga a un segundo nodo
⚠️MAX_SESIONES = 8 con 8 inquilinos · executor.replicas sin declararDos fronteras que hoy funcionan por coincidencia, no por decisión

Historial

VersiónFecha
v1.02026-08-12Capa 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.42026-08-12Capa 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.32026-08-12Capa 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.22026-08-12Capa 2: el registro. 5 motores, 5 cableados, 3 con SQL, 1 elegido. Y available = cableado, no sano
v0.12026-08-12Capa 1: el sustrato físico. Un nodo al 88 %, Spark 4.1.2 sobre Stackable 26.7.0, todo ClusterIP, y Trino ya fuera