B · El puente HTTP → Spark Connect — approach
Fecha: 2026-08-09 (sesión de noche). Sustituye a
r-starrocks-absorcion-approach.mdcomo
plan vigente para el SQL Editor. Aquél no se borra: su §3·bis (R2·bis) es una
medición válida y su gate de reentrada sigue en pie (§8).Estado del sustrato:
sustrato-02.md. Manda sobre esto.La tesis en una línea: un motor, un dialecto, una identidad — y la pieza que
falta es un traductor de medio núcleo, no un motor de ocho.
1 · La decisión, y el error que la precedió
Decisión del owner (2026-08-09): no se absorbe StarRocks. Spark sirve también el SQL Editor, a través de un puente HTTP que construimos y mantenemos nosotros.
⛔ El error, dicho con todas las letras
La absorción de StarRocks se planificó y se empezó a ejecutar sin volver a probar su premisa. La premisa era:
«No existe cliente Node para Spark Connect ⇒ hace falta otro motor.»
Eso es un non sequitur. No existe cliente Node ⇒ hace falta un traductor. Que el traductor sea «un sidecar que hay que desplegar, exponer, autenticar y mantener» se usó como argumento para descartarlo — y nunca se comparó contra desplegar, exponer, autenticar y mantener un motor distribuido entero.
Los números que esa comparación habría dado:
| Puente | StarRocks | |
|---|---|---|
| Cómputo | ~0,5 vCPU | 8 vCPU = 67 % de la plataforma |
| Dialectos a mantener | 1 | 3 (DuckDB, Spark, StarRocks) |
| Integraciones de identidad | 0 nuevas | 1 nueva (y contradice S5·1) |
| Exposición | HTTP/L7 | TCP/L4 público (MySQL wire) |
| Motores a operar | 0 nuevos | 1 |
⚠️ Y hay precedente exacto en este repo
lib/compute/engines/trino.ts declara en su cabecera que el gate para abrir esa
puerta era «una consulta MEDIDA que DuckDB no aguante». Ese gate nunca se
cumplió. Trino llegó a F7 —TLS, JWT, OPA, FTE, exposición— y junction.ts:1534
siguió cableado a DuckDB todo el tiempo. Se demolió el 2026-08-09.
⇒ Estábamos a un paso de repetirlo idéntico, con un motor que cuesta dos tercios
del cómputo. sustrato-02 §5·bis ya lo había escrito como regla —«un operador
instalado no es un motor absorbido»— y aun así se instaló el operador primero.
Las tres razones de StarRocks, revisadas
| Razón | Qué queda | |
|---|---|---|
| 1 | No hay cliente Node para Spark Connect | Cierta — y se resuelve con este puente |
| 2 | JWT identity passthrough | ⛔ Argumenta a favor de lo que S5·1 decidió NO querer («A · la cara es un proxy de confianza», para no duplicar política en dos almacenes) |
| 3 | Vended credentials | Ya lo tiene Spark — el gate 3 de S2 leyó bytes de R2 con credencial vendida |
2 · Puente en Python, y NO un cliente TS generado de los protos
Es una alternativa real y hay que descartarla con argumento, no por omisión. Los
.proto de Spark Connect son públicos (sql/connect/common/src/main/protobuf/spark/connect)
y Node tiene tooling gRPC maduro (@grpc/grpc-js, buf, ts-proto). Se podría
generar un cliente TypeScript. Snowflake llegó a implementar el servidor del
protocolo, así que como contrato es estable e implementable.
No lo hacemos, por dos razones — y la primera no es de esfuerzo:
- ⭐ Quién es el DUEÑO del contrato. Con el puente en Python, el binding del
protocolo es problema de
pyspark: cada upgrade de Spark lo absorbe la biblioteca oficial. Con un cliente generado, el binding es nuestro para siempre, y Spark Connect no versiona su protocolo para terceros. Es la misma lógica que puso la cintura estrecha en el catálogo y no en el motor: se depende de contratos que otros mantienen. - Vercel es serverless y Spark Connect es sesión larga. Las funciones son efímeras; sostener una sesión gRPC entre peticiones es exactamente lo que peor hacen. Un puente con estado, dentro del cluster, sostiene sesiones de verdad.
Y un tercer punto que cierra la discusión de seguridad: la documentación de Spark dice que Connect no trae autenticación y que «su interfaz gRPC HTTP/2 permite el uso de proxies autenticadores». El puente ES ese proxy. No es un parche: es el patrón que la propia documentación señala.
⬜ Descartados, y por qué
| Por qué no | |
|---|---|
| Apache Kyuubi | Resuelve la forma del problema (JDBC/ODBC/REST sobre Spark) pero tampoco tiene driver Node, y añade un servicio distribuido más que operar |
| Apache Livy | Sumisión de código (Scala/Python) por REST, no un servicio SQL; y arrastra el modelo pre-Connect |
| Arrow Flight SQL / ADBC | El estándar correcto a medio plazo, pero Spark Connect no lo habla y el ecosistema Node no está maduro |
3 · El diseño de referencia: la Statement Execution API de Databricks
No inventamos la forma. Databricks resolvió este problema exacto —SQL desde un cliente que no es JVM— y su API es la referencia. Lo que tomamos:
| Pieza | Qué es | Por qué nos sirve |
|---|---|---|
Asíncrona por defecto, con wait_timeout | POST /statements devuelve statement_id + estado; si termina dentro del timeout, los resultados vienen en la misma respuesta | Las queries pequeñas se sienten síncronas y las largas no bloquean una función de Vercel |
| Estados explícitos | PENDING · RUNNING · SUCCEEDED · FAILED · CANCELED · CLOSED | El estado terminal no se infiere del control-flow — la lección B1/B2 de Junction |
⭐ disposition: INLINE vs EXTERNAL_LINKS | INLINE ≤ 25 MiB y sólo JSON; EXTERNAL_LINKS devuelve URLs prefirmadas a los datos, en trozos, ARROW_STREAM o CSV, válidas ≤ 15 min | Los resultados grandes no pasan por el puente: Spark los deja en R2 y el puente devuelve enlaces. El puente se queda pequeño por diseño |
| Cancelación explícita | POST /statements/{id}/cancel | Una query huérfana en un editor es la forma normal de tirar un cluster |
⭐ EXTERNAL_LINKS encaja con lo que ya tenemos: R2 como almacenamiento y la
firma de URLs ya resuelta por el camino de credenciales vendidas / remote signing.
4 · Las fases, con su gate
| Fase | Gate — la salida que decide | |
|---|---|---|
| B0 | ⭐⭐ La latencia, medida | p50/p95 por Spark Connect |
| B1 | El puente mínimo | un SELECT desde fuera devuelve filas correctas |
| B2 | Identidad y multi-tenencia | el inquilino A no ve la tabla de B |
| B3 | Resultados grandes | un resultado > 25 MiB que NO atraviesa el puente |
| B4 | ⭐ El brazo de ESCRITURA | un DML cuyo snapshot lleva carbon.operation-id dentro del mismo commit |
| B5 | Un solo dialecto | las 25 sondas re-medidas contra Spark SQL |
| B5·b | el corpus de divergencias, a Spark | dialect-divergence.ts reescrito |
| B6 | La costura | junction.ts enruta la LECTURA a Spark |
| B7 | Mantenimiento | compaction + expiración corriendo solos |
4·bis · 🏁 B0 — MEDIDO, y el plan se sostiene
infra/gke/b0-latencia-connect.yaml · pod python:3.12 con pyspark[connect]==4.1.2
contra sc://carbon-connect-server.spark.svc:15002 · 12 repeticiones, 3 de
calentamiento descartadas · 2026-08-09
sesion_abierta_ms=599
FRIA select-1 p50=124.6ms p95=215.1ms n=9 1a=5832ms
FRIA aritmetica p50= 98.3ms p95=239.8ms n=9 1a= 233ms
FRIA range-agg p50=225.0ms p95=461.5ms n=9 1a=1769ms
SUELO_DEL_PUENTE_p50=145.2ms (planificacion + gRPC, sin storage)
⇒ ~145 ms p50 · ~240-460 ms p95. Para un editor SQL interactivo eso es holgado. El plan se sostiene.
⭐⭐ Y el dato que decide la ARQUITECTURA, no sólo el veredicto
primera query : 5.832 ms p50 en caliente : 125 ms -> 47×
Una sesión fría cuesta 47 veces más que una caliente. Eso:
- valida el diseño:
SparkConnectServerde larga vida + puente con estado; - mata de raíz la variante «que Vercel abra la sesión por petición» — sería ~6 s por query, y ningún editor sobrevive a eso;
- explica por qué
WARMUP=3no es cosmética: medir la primera corrida habría dado 🔴 sobre un coste que el usuario nunca paga en producción.
⭐ Y de paso confirma la sospecha sobre los 3,4 s de Trino
El suelo del puente es 23× menor que aquellos 3,4 s/query. ⇒ aquello no era el
motor: era la topología (GKE europe-west1 → R2 WEUR). Cambiar de motor nunca lo
habría arreglado, y el approach de StarRocks ya lo avisaba en su R4.
⚠️ Lo que B0 todavía NO dice
- La familia REAL no se midió (
TABLAvacía): falta el catálogo cableado en el Connect server. El coste de topología sigue sin cuantificar — esB0·b. - La sonda corre DENTRO del cluster. Desde Vercel hay un salto WAN más que hay que sumar y medir aparte.
- Un solo cliente. No dice nada de concurrencia — que es justo el gate de reentrada de StarRocks (§6).
⛔ Y una trampa de arnés más, la sexta de la sesión
El vigilante dio TIMEOUT con el resultado ya impreso en el log: comparaba la
columna STATUS de kubectl get pods —que dice Completed— contra el
vocabulario de fases del pod —que dice Succeeded—. El dato estaba; el arnés no
sabía mirarlo.
Misma familia que las cinco anteriores: el sistema y el arnés hablando
vocabularios distintos.Completed(columna) ≠Succeeded(fase).
⭐⭐ B0 va primero, y puede tumbar el plan entero
Es la única fase que puede invalidar la decisión, así que va antes de construir nada — exactamente el papel que jugó R2·bis en el plan anterior, y que allí funcionó.
Qué se mide: p50/p95 de un corpus de queries reales, con la sesión ya caliente
(el SparkConnectServer es de larga vida, así que el arranque de sesión no cuenta;
lo que cuenta es la planificación por query).
Y qué NO se puede concluir: los 3,4 s/query medidos con Trino eran
topología —GKE europe-west1 → R2 WEUR—, no motor. Si el puente da esos
mismos 3,4 s, el culpable sigue siendo la topología y cambiar de motor no lo
arregla. El gate debe separar las dos cosas: medir también una query que no toque
storage (SELECT 1, una agregación sobre caché) para aislar planificación de
latencia de red.
⚠️ Veredicto enumerado. Si una corrida no da ni el éxito ni el fallo esperados,
sale NO_MEDIDO con código propio. No se aprueba por ausencia de la palabra mala
(sustrato-02 §5·bis).
4·ter · 🏁 B1 — el puente existe y sirve SQL
services/spark-bridge/app.py · infra/gke/b1-spark-bridge.yaml ·
gate scripts/bridge/b1-gate.ts · 2026-08-09
[OK] P0 readyz EJECUTA SELECT 1 en 65ms
[OK] P1 SELECT sincrono filas=[[1,2]] en 104ms
[OK] P2 asincrona + sondeo terminal=SUCCEEDED n=[[20000000]]
[OK] P3 negativo (SQL malo) FAILED y explica: AnalysisException: [TABLE_OR_VIEW_NOT_FOUND] …
[OK] P4 cancelar cancelada=CANCELED · terminal-no-reescribible=SUCCEEDED
[OK] P5 id desconocido status=404
VEREDICTO: VERDE — 6/6
~100 ms por query a través del puente, consistente con el suelo que midió B0.
Las tres decisiones de diseño, y de dónde salen
- ⭐ Una sesión, de larga vida, creada bajo
lock. No es implementación: es la razón de existir del servicio. B0 midió 5.832 ms en frío contra 125 ms en caliente (47×) ⇒ abrir sesión por petición —lo que haría Vercel llamando directo— sería ~6 s por query. Por eso el puente tiene estado y no es una función. - El estado terminal es un CAMPO, no una inferencia del control-flow. Es la lección B1/B2 de Junction, aplicada aquí desde el principio. Y P4 lo fija: cancelar algo ya terminado no lo reescribe.
healthz≠readyz. El primero dice que el proceso vive; el segundo ejecutaSELECT 1. Un health-check que no ejecuta nada declara «sano» un camino que no existe — la lección de la demolición del plano PG. El readiness del pod usa el caro, así que1/1ya significa «el camino a Spark funciona».
⛔⛔ Y la trampa nº7, que me hice yo con el arreglo de la nº2
El gate pasó 6/6 a la primera… con P3 diciendo esto:
FAILED y explica: n(SparkConnectPlanExecution.scala:96) at org.apache.spark…
Marcos de pila. Ningún mensaje. Porque tras aprender que en Py4JJavaError la
causa va al FINAL, corté los errores por la cola — y en Spark Connect una
AnalysisException pone el mensaje (TABLE_OR_VIEW_NOT_FOUND) al PRINCIPIO y
luego cuarenta marcos. La misma trampa, por el otro lado.
Regla: no se elige un extremo al truncar un error: se conservan los dos y se
marca el hueco._resumir_error()guarda cabeza y cola. Tras el arreglo, P3 dice
AnalysisException: [TABLE_OR_VIEW_NOT_FOUND] The table or view ….
⚠️ Y nótese que el gate habría pasado igual: comprobaba error.length > 20. Un
umbral de longitud no es una comprobación de utilidad.
⬜ Lo que B1 deja fuera, a propósito
- ⛔ Sin autenticar y
ClusterIP. Un puente abierto a internet es acceso sin credenciales a todo el warehouse. Sale del cluster en B2. - ⚠️ Instala dependencias en el arranque desde
python:3.12-slim: vale para el gate, no para producción (cada reinicio depende de PyPI). Imagen propia en B2. - ⚠️ Cancelar marca la sentencia pero NO interrumpe el job en Spark — hace falta
interruptTagcon etiquetas por sesión. Está dicho en el código para que nadie crea que libera cómputo. - ⚠️ El gate viaja por
port-forward: prueba el contrato HTTP y el camino a Spark, no la exposición pública.
4·quater · 🏁 B2·0 y B2·1 — el sustrato de la multi-tenencia
⛔ B2·0 · el SparkConnectServer estaba DESNUDO
Sin Iceberg, sin extensiones, sin catálogo. B0 midió 145 ms ejecutando SELECT 1
porque el puente no podía tocar el warehouse. Arreglado vía spec.args:
args:
- "--packages"
- "org.apache.iceberg:iceberg-spark-runtime-4.1_2.13:1.11.0"
- "--conf"
- "spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions"
⚠️ Ni spec.deps ni spec.sparkConf: existen en SparkApplication y no en
SparkConnectServer (unknown field). Segunda vez en el mismo fichero que los
dos CRD divergen — la primera fue 2G vs 2Gi.
⭐⭐ B2·1 · el catálogo ES por sesión — MEDIDO
infra/gke/b2-1-sesion-catalogo.yaml
P0 control positivo = OK
P1 catalogo en sesion A = RESPONDE namespaces=['main']
P2 AISLAMIENTO = OK — B no ve cat_a
[SCHEMA_NOT_FOUND] `spark_catalog`.`cat_a` cannot be found
P3 catalogo en sesion B = RESPONDE namespaces=[]
VEREDICTO: VERDE — el catalogo es POR SESION. Un servidor, N inquilinos.
⇒ Un solo Connect server sirve a N inquilinos. La alternativa —un servidor por inquilino, con su coste y su operación— queda descartada con medición, no por optimismo.
P2 es el que decide, y su evidencia es precisa: la sesión B no falló con un
error genérico; resolvió cat_a como schema bajo spark_catalog, o sea el
catálogo literalmente no existe en su sesión. El mensaje dice por qué, no sólo
que falló.
⚠️ Y P3 es más débil de lo que parece, hay que decirlo: devolvió lista vacía,
no contenido. Vacío es ambiguo —el propio S4 lo documenta— así que P3 no prueba
«B ve lo suyo». Lo que sí prueba, y basta para el gate, es que A y B ven cosas
DISTINTAS: si la config fuera global, B habría visto ['main'] igual que A. El
prefijo viaja por sesión. Cuando el inquilino B tenga tablas, conviene re-medirlo
con contenido.
⚠️ Y una API que NO existe en Connect, y es la que uno usaría
base.newSession()
-> [JVM_ATTRIBUTE_NOT_SUPPORTED] Attribute `newSession` is not supported
in Spark Connect as it depends on the JVM
La sesión nueva se pide con SparkSession.builder.remote(...).create() —frente a
.getOrCreate(), que reutiliza la activa—. No es cosa de la sonda: es cómo el
puente tiene que abrir la sesión de cada inquilino.
⇒ Misma familia que spec.deps: Spark Connect no es Spark con otro transporte, es
una superficie más estrecha, y las diferencias no avisan hasta que las tocas.
B2 · La identidad, y por qué es coherente con lo ya decidido
Vercel (JWT del usuario) → PUENTE valida y fija el contexto de inquilino
→ Spark Connect (sesión por inquilino)
→ la cara /api/iceberg → Index autoriza → Lakekeeper → R2
El puente valida el JWT y no lo reenvía: Spark se presenta ante la cara con su propia credencial. Eso es el proxy de confianza que S5·1 decidió, sin duplicar política en dos almacenes.
⚠️ Y aquí está la deuda honesta de este diseño. Con StarRocks el catálogo habría
visto al usuario; aquí ve a lakekeeper-spark. El aislamiento por inquilino tiene
que aplicarlo el puente + el prefijo de inquilino de la cara, y eso hay que
diseñarlo y probarlo con control negativo, no darlo por hecho. Es el único punto
donde StarRocks era genuinamente mejor.
⚠️ Sesión por inquilino, no por usuario ni una global. Spark Connect aísla por sesión; una sesión compartida entre inquilinos es una fuga esperando a ocurrir, y una por usuario no escala en memoria del driver.
4·quinquies · 🏁 B2 — identidad y aislamiento, MEDIDOS
services/spark-bridge/{identidad,sesiones}.py · gate scripts/bridge/b2-gate.ts
[OK] G0 sin token status=401
[OK] G1 firma invalida status=401
[OK] G1b caducado status=401
[OK] G2 A lee SU tabla count=6 en 7450ms
[OK] G3 NEGATIVO B->tabla A DENEGADO — ForbiddenException:
«la tabla no es del inquilino declarado (w_cccc70a5…)»
[OK] G4 sentencia ajena propio=200 ajeno=404
[OK] G5 pool por inquilino vivas=2 prefijos=[w_7b500f4d…, w_cccc70a5…]
VEREDICTO: VERDE — 7/7
⭐⭐ Lo que G3 demuestra, y no es lo que parece
Quien deniega no es el puente: es la CARA. El mensaje —«la tabla no es del inquilino declarado»— es la comprobación cruzada de Index, no una regla del puente.
⇒ El aislamiento no descansa en el componente nuevo. Aunque al puente lo engañaran para declarar otro inquilino, Index sigue negando porque la tabla contradice al prefijo. Dos líneas de defensa, y la de dentro es la que sujeta. Es exactamente la propiedad que se buscaba al elegir proxy de confianza en S5·1.
⚠️ Y G2 da count=6: son las 6 filas que dejó S5·1. El camino de escritura y
el de lectura del puente ven el mismo dato, por la misma puerta.
Las decisiones, y de dónde salen
| Decisión | Por qué | |
|---|---|---|
| Token propio de la app (owner) | sub + workspaceId, firmado con secreto compartido, vida de segundos | El Next.js ya sabe qué workspace tiene delante. Con el JWT de Keycloak el puente tendría que consultar el control-plane: dependencia de BD y latencia por sesión |
| El prefijo se DERIVA, no viaja | w_<uuid sin guiones> desde el workspaceId | Un prefijo transportado sería un segundo sitio donde mentir |
| Algoritmos en lista cerrada | algorithms=[HS256] | Sin eso, alg: none entra |
| ⛔ Sin secreto ⇒ rechaza TODO | nunca degrada a «permitir» | Un puente que se abre porque falta una variable de entorno es la forma más silenciosa de publicar el warehouse |
| Pool LRU por INQUILINO (owner) | tope 8, inactividad 30 min | Por usuario no escala en memoria del driver; global sería una fuga. El inquilino es la unidad de aislamiento del catálogo |
| Podar por inactividad ANTES que por tope | Al revés se podría expulsar una sesión activa conservando una muerta | |
| Sentencia ajena ⇒ 404, no 403 | Un 403 confirma que el id existe: ya es filtrar |
⚠️ Dos cosas que el verde no debe tapar
- G2 tardó 7.450 ms — la sesión FRÍA del inquilino A, coherente con los 5.832 ms
de B0. En caliente son ~125 ms. El primer usuario de cada inquilino paga el
arranque, y con
INACTIVIDAD_S=1800lo vuelve a pagar cada vez que su inquilino lleva media hora quieto. Si molesta, la palanca es precalentar, no subir el tope. - Cancelar marca la sentencia pero NO interrumpe el job en Spark. Falta
interruptTagpor sesión.
B4 · La escritura entra por el MISMO camino — y ya está medio construida
S5·1 construyó el mecanismo: las opciones snapshot-property.* de Iceberg viajan
dentro del mismo commit que los datos, así que el dato y su marca de procedencia
no se pueden desincronizar. Eso retira el veredicto unverifiable de ratify.ts
y el attributable: false de withDmlControlPlane.
Prácticas de la industria que adoptamos, con su porqué:
MERGE INTOen vez deINSERT OVERWRITE: reescribe sólo los ficheros afectados y su comportamiento es más predecible.- ⭐ La optimización que más rinde en un
MERGEes un predicado de partición en elON— permite a Iceberg podar ficheros. No es micro-optimización: es la diferencia entre reescribir una partición y reescribir la tabla. - Merge-on-read por defecto, no copy-on-write: los deletion vectors de la v3 reducen mucho el sobrecoste de lectura del MOR. Copy-on-write sólo si el patrón es «se actualiza poco, se lee mucho».
B5 · Un dialecto, y no es gratis
Hoy el editor habla DuckDB; el resto de la plataforma, Spark SQL. Unificar elimina
la clase de fallo que el arnés F3 existe para cazar —timestamp-arithmetic: sintaxis
correcta, resultado distinto, sin lanzar— en vez de gestionarla.
⚠️ Dos avisos que hay que tener delante:
dialect-divergence.tsdice hoy «Carbon SQL es Trino SQL» y las 25 sondas están medidas contra un motor que ya no existe. Hay que reescribirlo y volver a medir.- ⭐ Spark 4 trae ANSI activado por defecto (
spark.sql.ansi.enabled=true). Casts inválidos, desbordamiento aritmético, división por cero e índices fuera de rango lanzan donde antes devolvíannull. Es un cambio de semántica de cara al usuario del editor, no sólo de sintaxis. Se decide a propósito si se deja ANSI o se apaga — y se documenta.
B7 · Y de paso, el que nadie estaba haciendo
144 tablas Iceberg sin compaction ni expiración de snapshots. No es un problema
que llegue con la escala: llega con el tiempo. Spark trae los cuatro
procedimientos (rewrite_data_files, expire_snapshots, remove_orphan_files,
rewrite_manifests) en la extensión de Iceberg.
⇒ Otro argumento para un solo motor: el que sirve el editor es el mismo que
mantiene las tablas. Approach:
s5-write-maintenance-approach.md, donde el
orden de las cuatro operaciones se mide, no se copia del blog.
5 · Lo que este plan NO resuelve — dicho antes, no después
| Riesgo | Cómo se cierra | |
|---|---|---|
| ⭐ La latencia interactiva de Spark no está medida | Puede ser 200 ms o 2 s | B0, y va primero |
| ⭐ La concurrencia de Spark no es la de un motor de serving | Para unos analistas sobra; para 100 queries simultáneas, no | Es el gate de reentrada de StarRocks (§6) |
| El aislamiento por inquilino lo aplica el puente | Con StarRocks lo habría aplicado el catálogo | B2, con control negativo |
| Migrar el editor de DuckDB a Spark SQL cambia el dialecto de cara al usuario | No es gratis | B5 — pero el techo de DuckDB (ATTACH es de la INSTANCIA ⇒ un duck-server no sirve a N inquilinos con warehouse propio) fuerza el movimiento igualmente |
| Spark queda capado a 1 nodo | CPUS_ALL_REGIONS = 12, subida denegada | Pasar a cuenta de pago. Es el desbloqueo de esto y de Spot |
6·bis · 🏁 B6 — la costura, cosida
Es la línea que decidió el destino de Trino. Llegó a F7 —TLS, JWT, OPA, FTE,
exposición— y junction.ts siguió diciendo duckdb todo el tiempo; se demolió
sin haber servido una sola query de usuario.
| Fichero | Cambio |
|---|---|
lib/compute/contract.ts:78 | Engine: fuera trino, dentro spark |
lib/compute/engine-adapter.ts:40 | EngineDialect: ídem |
lib/compute/engines/spark.ts | nuevo · habla con el puente por HTTP |
lib/compute/engines/trino.ts | borrado — el motor no existe |
lib/compute/engines/index.ts | una entrada en el REGISTRY |
lib/compute/junction.ts | elegirMotorDeLectura() sustituye a getSqlEngine('duckdb') |
358/358 tests (compute + governance), tsc limpio.
La política, en una función con nombre
export function elegirMotorDeLectura(esWrite: boolean) {
if (esWrite) return getSqlEngine('duckdb');
const spark = getEngineAdapter('spark').capabilities();
return getSqlEngine(spark.available && spark.sql ? 'spark' : 'duckdb');
}
Que viva en una función exportada, y no inline, es lo que hace que «¿qué motor
sirve el editor?» tenga una respuesta que se lee, y que se pueda probar sin
levantar Junction entera (b6-seam.test.ts, 5 tests).
- El write se queda en DuckDB por CONTRATO, no por precaución: el adaptador de
Spark declara
write:falseporque el brazo de escritura del puente es B4. Preguntarlo aquí duplicaría la política; se consulta al registry, que la tiene. - ⛔ NO hay
try { spark } catch { duckdb }. Un fallback por excepción convierte «el puente está caído» en «la query salió con otro dialecto», en silencio. Y no es teórico:timestamp-arithmeticda resultado distinto sin lanzar. Degradar de motor es degradar de semántica, y eso se decide arriba.
⚠️ Nace INERTE, y eso es la mitad del diseño
El puente es ClusterIP: Vercel no lo alcanza. Sin SPARK_BRIDGE_URL +
SPARK_BRIDGE_JWT_SECRET, el adaptador declara indisponibilidad y todo sigue
exactamente como estaba. Encenderlo son dos variables de entorno, y apagarlo
también.
⚠️ Y se exige que estén las dos: con URL y sin secreto el puente respondería 401 a todo, así que anunciarse disponible ahí sería el fallo más caro — Junction elegiría Spark y ninguna query funcionaría.
Dos cosas que cazó el compilador, y que habrían llegado a la UI
- ⭐
RowesRecord<string, unknown>, y el puente devuelve ARRAYS. Sin la conversión, la UI habría recibido filas sin las claves que busca — o sea «la query no devuelve datos», que es el diagnóstico equivocado. Lo cazótsc. featuresesreadonly string[], noSqlFeature[]. El test viejo lo forzaba y el error estaba tapado por el «módulo no encontrado» de Trino.
⚠️ Y una contaminación de suite, que también es del día
Los tests nuevos dejaban SPARK_BRIDGE_* puestas y hacían fallar a
availableSqlEngines refleja el cableado real con un «esperaba 0, hubo 1» — un
rojo que no habla del sistema sino de la suite. Un test que asserta una propiedad
GLOBAL tiene que limpiar todos los motores, no los que existían cuando se
escribió.
6·ter · 🔴 B5 — el dialecto, DERIVADO. Y sale ROJO
scripts/bridge/b5-dialecto.ts · 25 sondas contra este Spark · 2026-08-09
SOPORTADAS 16/25 · ruidosas 7 · ⛔ SILENCIOSAS 2 · sin medir 0
VEREDICTO: ROJO — hay divergencia SILENCIOSA
Las 7 ruidosas son baratas —QUALIFY, DISTINCT ON, json_extract_string,
literales […]/{…}, VARCHAR sin tamaño, regexp—: fallan con error y se ven al
instante.
⛔⛔ Las 2 que justifican el arnés entero
DATE '2026-01-02' - DATE '2026-01-01'
DuckDB -> 1 (entero de días)
Spark -> "P1D" (intervalo ISO-8601) <- corre, no lanza, MIENTE
traducción disponible: datediff(a, b) -> 1
ORDER BY x con NULL
DuckDB -> [1, 2, null] (NULLS LAST)
Spark -> [null, 1, 2] (NULLS FIRST)
⭐⭐ Y el hallazgo de verdad: la etiqueta de la sonda MENTÍA
La segunda divergencia la reportó la sonda grouping-sets. Verificado aparte:
GROUPING SETS sin ORDER BY -> [[1,2],[null,3],[2,1]] ✅ la feature FUNCIONA
El problema no son los grouping sets: es el orden de los NULL — y eso afecta a
CUALQUIER query ordenada con columna nullable, que es prácticamente todo un editor
SQL. La sonda lo cazó de rebote, porque su resultado esperado incluye una fila
NULL.
Fiarse de la etiqueta habría «arreglado» lo que no estaba roto y dejado intacto
un fallo transversal. Una sonda te dice DÓNDE mirar, no QUÉ está mal.
⭐ Spark y Trino divergen casi en los mismos sitios
Comparado el corpus heredado (8 divergencias DuckDB↔Trino) con lo medido en Spark: coinciden 7 de 8. Sólo difieren en tres puntos, y el test los congela:
| sólo Trino | limit-offset — Spark SÍ soporta LIMIT n OFFSET m |
| sólo Spark | timezone-cast · grouping-sets (el orden de los NULL) |
Porque los dos son de linaje ANSI/JVM y DuckDB habla sabor PostgreSQL. El corpus resultó más transferible de lo que parecía.
⚠️ Y produjo un rojo falso al reusarlo tal cual: el invariante «ninguna
divergencia declarada como soportada» falló en limit-offset, que en Spark no
diverge. Un invariante medido contra el motor equivocado es peor que ninguno.
Qué queda, y qué NO se hace todavía
| ✅ | features del adaptador pobladas con las 16 medidas (no declaradas) |
| ✅ | tests reapuntados: los dos disparadores que puse en B6 saltaron como debían |
| 🔴 | No se enciende Spark en el editor hasta decidir cada silenciosa: traducir (datediff), forzar NULLS LAST, o asumir |
| ⬜ | B5·b: dialect-divergence.ts sigue describiendo traducciones a Trino. Reescribirlo a Spark, con el delta ya congelado en el test |
| ⬜ | ANSI: Spark 4 lo trae activado y el corpus no lo cubre. Casts inválidos, desbordamiento y división por cero lanzan donde DuckDB da null. Hueco conocido |
6·quater · 🏁 Las dos silenciosas, cortadas · y B5·b
Primero se preguntó al motor, no a la documentación
SET -v -- 287 confs
| Busqué | Resultado |
|---|---|
| conf para el orden de los NULL | ninguna de las 287 |
spark.sql.legacy.interval.enabled | no existe en este build |
spark.sql.ansi.enabled | ⭐ true — confirmado en vivo |
⇒ Ninguna de las dos se arregla por configuración. Y reescribir el SQL por texto está descartado por precedente propio: la sustitución textual corrompía literales (la nota M2c del adaptador de DuckDB). Un parser de Spark SQL en Node no existe.
El arreglo: de silencioso a RUIDOSO
sparkEngine.runQuery llama a blockingForSpark(sql) antes del round-trip y
rechaza con EngineCapabilityError, poniendo el equivalente en el mensaje:
SELECT DATE 'a' - DATE 'b' -> rechazada · «En Spark SQL se escribe: datediff(a, b)»
SELECT x FROM t ORDER BY x -> rechazada · «… ORDER BY x NULLS LAST»
SELECT x FROM t ORDER BY x NULLS LAST -> PASA
No se traduce, se rechaza — y es deliberado. Traducir la resta de fechas exigiría saber que los operandos SON fechas, y eso vive en el catálogo, no en el texto. Una traducción que acierta el 90 % convierte un fallo ruidoso en uno silencioso, que es exactamente el problema que se está resolviendo.
⚠️ Y el rechazo es accionable o no sirve: un test exige que el mensaje contenga
datediff / NULLS LAST. Un muro sin salida es tan inútil como el fallo.
⭐ null-ordering no es un bug de Spark: es un riesgo del SEAM
El estándar SQL deja el orden de los NULL definido por la implementación — ni
DuckDB ni Spark están «mal». El peligro nace en B6: elegirMotorDeLectura() puede
mandar la MISMA query a uno o a otro según una variable de entorno, y el usuario
vería otro orden sin haber tocado nada.
⇒ Se rechaza mientras convivan dos motores. El día que Spark sea el único, esto deja de ser divergencia y pasa a ser, sencillamente, el comportamiento.
B5·b · el corpus, reescrito
dialect-divergence.ts: campo trino→spark, blockingForTrino→blockingForSpark,
y las 9 entradas con lo medido. 5 traducir · 2 reescribir · 2 rechazar.
⚠️ null-ordering es la única sin sonda propia, y es deliberado: no es una
capacidad que un motor tenga o no, es cómo ordena. Existe como SqlFeature sólo para
poder nombrarla donde importa.
El delta con el corpus de Trino queda congelado en el test (limit-offset sale;
timezone-cast y null-ordering entran), para que la próxima reescritura no se haga
a ojo.
⛔ Y la trampa nº8, en el propio arreglo
Al generar el corpus con un script Python, \b no dio error: es el carácter
BACKSPACE, y se coló invisible en catorce regex. \w sí avisó —no es escape
válido— y por eso el aviso señalaba al sitio equivocado. Los detectores quedaron sin
sus límites de palabra y no detectaban nada; los tests lo cazaron.
Misma familia que las siete anteriores: un escape que se convierte en silencio en
otra cosa. El aviso del compilador señalaba\w; el daño estaba en\b.
6·quinquies · 🟡 La exposición del puente — sql.paladio.io
infra/gke/b2-expose-bridge.yaml · 2026-08-09
| Pieza | Estado |
|---|---|
IP global reservada carbon-bridge-lb | 🏁 8.232.149.8 |
Autorización DNS carbon-sql-dnsauth | 🏁 creada |
Certificado carbon-sql-cert + map | 🟡 PROVISIONING / AUTHORIZING |
Secret placeholder TLS | 🏁 creado antes que el Gateway |
Gateway + HTTPRoute → spark-bridge | 🏁 Programmed=True · GatewayHealthy=True |
HealthCheckPolicy → /healthz | 🏁 sin ella: 503 (ver abajo) |
| Los dos registros DNS | 🏁 puestos · trino borrado (NXDOMAIN verificado) |
https://sql.paladio.io | 🏁 VIVO · gate B2 8/8 contra el dominio público |
subject CN=sql.paladio.io issuer Google Trust Services, CN=WR3
válido hasta 2026-11-07 tls_verify = 0
[OK] G0/G1/G1b 401 sin token · firma invalida · caducado
[OK] G2 A lee SU tabla count=6 en 1.700 ms (en caliente, por internet)
[OK] G3 NEGATIVO B->tabla A DENEGADO por la CARA
[OK] G4 sentencia ajena propio=200 · ajeno=404
[OK] G5 pool vivas=2, prefijos distintos
[OK] G6 healthz NO filtra admin/pool sin token=401 · healthz={ok, sesiones_vivas}
VEREDICTO: VERDE — 8/8
⚠️ 1.700 ms en caliente por internet (contra los 145 ms de suelo de B0): la diferencia es WAN + storage, no motor. La primera fue de 5.190 ms — el 47× de la sesión fría, otra vez, ahora medido de punta a punta.
⛔ El 503 que no dice qué le pasa
Con TLS perfecto y el pod 1/1 Running, el dominio devolvía no healthy upstream.
Causa: GKE sondea / por defecto y el puente responde 404 ahí. El mensaje no
menciona ni la ruta ni el health-check — se lee como «el pod está caído».
⭐ Y la HealthCheckPolicy apunta a /healthz, NO a /readyz, a propósito:
readyz ejecuta SELECT 1 contra Spark. Colgar el health-check del balanceador
de una query haría que una incidencia de Spark sacara el puente entero de
rotación, incluidos los endpoints que no la necesitan. El readiness del pod sí
usa readyz — ahí esa dependencia se quiere; en el balanceador, no.
⚠️ Y el gate se quedó rancio respecto a mi propio arreglo
G5 leía el pool de /healthz… de donde yo acababa de quitarlo por seguridad.
Se puso ROJO en vez de pasar en falso, que es lo que se le pide a un arnés cuando
el sistema cambia debajo. Reapuntado a /admin/pool, y de paso el arreglo pasó a ser
test: G6 exige 401 sin token y que /healthz no contenga ningún w_<hex>.
CNAME _acme-challenge.sql.paladio.io
→ e8f98d53-9607-4ff4-af1c-238c4f30ad55.18.authorize.certificatemanager.goog.
A sql.paladio.io → 8.232.149.8
El certificado no pasa a ACTIVE sin el CNAME, y el Gateway sirve 404 sin el A.
⭐ Lo que se sacó a internet, y lo que NO
Se publica el puente (HTTP, con token por petición), no Spark Connect: el gRPC
sigue cluster-internal. Lo que queda expuesto es una API que autentica cada
llamada.
⚠️ Y la seguridad descansa en un secreto SIMÉTRICO. Quien tenga
BRIDGE_JWT_SECRET puede acuñar tokens para cualquier inquilino. Vive sólo en
Vercel y en el Secret de k8s, y los tokens duran 120 s — eso mitiga, no elimina.
Rotarlo periódicamente es lo correcto, y es barato.
⛔ Un agujero propio, encontrado al ir a exponer
/healthz va sin autenticar por definición… y devolvía POOL.estado(), que lista
los prefijos de inquilino de las sesiones vivas. Mientras el puente era
ClusterIP daba igual; publicado, reparte el censo de qué inquilinos están
activos a quien lo pida.
Recortado a {ok, sesiones_vivas}; el detalle se movió a /admin/pool, autenticado.
Exponer un servicio cambia la clasificación de todo lo que ya devolvía. Lo que
era diagnóstico interno pasa a ser información pública sin que nadie edite una
línea.
🏁 La cara desplegada, y el hueco de metadata verificado
Deploy node-jqzh-4oh8jmiaf (--archive=tgz, obligatorio por el tope de 5.000
ficheros). Verificado contra producción, sin escribir nada:
tabla base 200 OK
metadata .snapshots 404 NoSuchTableException <- del CATÁLOGO, no de la cara
⛔ inventada bajo la tabla 403 unknown-table <- sigue rechazando
⛔ .snapshots sin tabla base 403 unknown-table <- sigue rechazando
⇒ La cara ya autoriza los nombres de tabla de metadata y los reenvía, y los dos controles negativos siguen en 403: no se relajó nada.
⚠️ Y lo que este 404 NO prueba, dicho antes de que alguien lo lea de más: las
tablas de metadata de Iceberg no se piden al catálogo REST — el cliente carga la
tabla BASE y las computa localmente. Que Lakekeeper devuelva 404 es lo esperado.
Queda probado que la puerta ya no bloquea; NO queda probado que Spark lea
.snapshots de punta a punta. Eso sólo lo dice correr el gate de S5·1.
⛔ Y la sonda se equivocó a la primera: construyó la URL sin la tabla base en el namespace y dio 403 en todo. La pista estaba delante — la tabla inventada daba el mismo error que la de metadata, que es lo que pasa cuando el control negativo y el positivo fallan por la misma razón ajena al experimento.
⚠️ Y la lección del Gateway anterior, aplicada
El Secret placeholder se creó antes que el Gateway y su comando está en la
cabecera del mismo fichero. La demolición de Trino borró el suyo y dejó aquel
Gateway en Programmed: False durante horas con GWCER102: Secret not found
(sustrato-02 §10). Separar el placeholder del Gateway es cómo se repite.
⚠️ Y una trampa de tooling: openssl req -subj "/CN=…" en Git Bash falla porque
MSYS convierte /CN=… en una ruta de Windows, con un error que no menciona rutas.
MSYS_NO_PATHCONV=1 delante.
6 · El asiento de StarRocks queda preparado y vacío
El EngineAdapter y el registry ya existen: entrar es una entrada en un
Record<Engine, EngineAdapter> —mapa total, si falta el adaptador rompe en tsc—
más junction.ts:1534.
⭐ Y esta vez el gate se escribe ANTES, que es lo que no se hizo con Trino:
StarRocks entra el día que exista una consulta MEDIDA, con concurrencia de
usuarios REALES, que Spark por el puente no aguante. No antes. Un operador
instalado no es un motor absorbido.
Lo que ya está en el banco y no se repite el día que ese gate se cumpla:
- 🏁 R2·bis:
mysql2cierra el handshake JWT. Formato de cable medido:[0x01][entero length-encoded][token]. Ojo: hacen falta las 4 ramas del entero, la sonda sólo ejercitó0xfc. - 🏁 El BE no corre en el equipo de desarrollo (Sandy Bridge, sin AVX2).
- 🏁 FE + BE no caben en un
n2-standard-4; hace faltan2-standard-8.
7 · Fuentes
- Spark Connect Overview — gRPC + protobuf + Arrow · clientes oficiales PySpark y Scala · «no built-in authentication… allows for the use of authenticating proxies» · aislamiento por sesión
- Databricks · Statement Execution API · tutorial —
disposition,INLINE25 MiB,EXTERNAL_LINKS,ARROW_STREAM, enlaces ≤ 15 min - Iceberg · Spark Writes · Dremio · COW vs MOR —
MERGE INTOsobreINSERT OVERWRITE, predicado de partición, deletion vectors v3 - Spark 4 · ANSI Compliance · qué rompe
- Snowflake · Spark Connect Engine — el protocolo como contrato implementable
- Apache Kyuubi · Apache Livy — los descartados