Published

B · El puente HTTP → Spark Connect — approach

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...

B · El puente HTTP → Spark Connect — approach

Fecha: 2026-08-09 (sesión de noche). Sustituye a
r-starrocks-absorcion-approach.md
como
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:

PuenteStarRocks
Cómputo~0,5 vCPU8 vCPU = 67 % de la plataforma
Dialectos a mantener13 (DuckDB, Spark, StarRocks)
Integraciones de identidad0 nuevas1 nueva (y contradice S5·1)
ExposiciónHTTP/L7TCP/L4 público (MySQL wire)
Motores a operar0 nuevos1

⚠️ 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ónQué queda
1No hay cliente Node para Spark ConnectCierta — y se resuelve con este puente
2JWT identity passthroughArgumenta 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)
3Vended credentialsYa 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:

  1. 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.
  2. 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 KyuubiResuelve 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 LivySumisión de código (Scala/Python) por REST, no un servicio SQL; y arrastra el modelo pre-Connect
Arrow Flight SQL / ADBCEl 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:

PiezaQué esPor qué nos sirve
Asíncrona por defecto, con wait_timeoutPOST /statements devuelve statement_id + estado; si termina dentro del timeout, los resultados vienen en la misma respuestaLas queries pequeñas se sienten síncronas y las largas no bloquean una función de Vercel
Estados explícitosPENDING · RUNNING · SUCCEEDED · FAILED · CANCELED · CLOSEDEl estado terminal no se infiere del control-flow — la lección B1/B2 de Junction
disposition: INLINE vs EXTERNAL_LINKSINLINE ≤ 25 MiB y sólo JSON; EXTERNAL_LINKS devuelve URLs prefirmadas a los datos, en trozos, ARROW_STREAM o CSV, válidas ≤ 15 minLos 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ícitaPOST /statements/{id}/cancelUna 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

FaseGate — la salida que decide
B0⭐⭐ La latencia, medidap50/p95 por Spark Connect
B1El puente mínimoun SELECT desde fuera devuelve filas correctas
B2Identidad y multi-tenenciael inquilino A no ve la tabla de B
B3Resultados grandesun resultado > 25 MiB que NO atraviesa el puente
B4⭐ El brazo de ESCRITURAun DML cuyo snapshot lleva carbon.operation-id dentro del mismo commit
B5Un solo dialectolas 25 sondas re-medidas contra Spark SQL
B5·bel corpus de divergencias, a Sparkdialect-divergence.ts reescrito
B6La costurajunction.ts enruta la LECTURA a Spark
B7Mantenimientocompaction + 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: SparkConnectServer de 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=3 no 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

  1. La familia REAL no se midió (TABLA vacía): falta el catálogo cableado en el Connect server. El coste de topología sigue sin cuantificar — es B0·b.
  2. La sonda corre DENTRO del cluster. Desde Vercel hay un salto WAN más que hay que sumar y medir aparte.
  3. 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

  1. 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.
  2. 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.
  3. healthzreadyz. El primero dice que el proceso vive; el segundo ejecuta SELECT 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í que 1/1 ya 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 interruptTag con 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ónPor qué
Token propio de la app (owner)sub + workspaceId, firmado con secreto compartido, vida de segundosEl 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 viajaw_<uuid sin guiones> desde el workspaceIdUn prefijo transportado sería un segundo sitio donde mentir
Algoritmos en lista cerradaalgorithms=[HS256]Sin eso, alg: none entra
Sin secreto ⇒ rechaza TODOnunca 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 minPor 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 topeAl revés se podría expulsar una sesión activa conservando una muerta
Sentencia ajena ⇒ 404, no 403Un 403 confirma que el id existe: ya es filtrar

⚠️ Dos cosas que el verde no debe tapar

  1. 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=1800 lo vuelve a pagar cada vez que su inquilino lleva media hora quieto. Si molesta, la palanca es precalentar, no subir el tope.
  2. Cancelar marca la sentencia pero NO interrumpe el job en Spark. Falta interruptTag por 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 INTO en vez de INSERT OVERWRITE: reescribe sólo los ficheros afectados y su comportamiento es más predecible.
  • La optimización que más rinde en un MERGE es un predicado de partición en el ON — 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:

  1. dialect-divergence.ts dice 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.
  2. 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ían null. 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

RiesgoCómo se cierra
La latencia interactiva de Spark no está medidaPuede ser 200 ms o 2 sB0, y va primero
La concurrencia de Spark no es la de un motor de servingPara unos analistas sobra; para 100 queries simultáneas, noEs el gate de reentrada de StarRocks (§6)
El aislamiento por inquilino lo aplica el puenteCon StarRocks lo habría aplicado el catálogoB2, con control negativo
Migrar el editor de DuckDB a Spark SQL cambia el dialecto de cara al usuarioNo es gratisB5 — 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 nodoCPUS_ALL_REGIONS = 12, subida denegadaPasar 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.

FicheroCambio
lib/compute/contract.ts:78Engine: fuera trino, dentro spark
lib/compute/engine-adapter.ts:40EngineDialect: ídem
lib/compute/engines/spark.tsnuevo · habla con el puente por HTTP
lib/compute/engines/trino.tsborrado — el motor no existe
lib/compute/engines/index.tsuna entrada en el REGISTRY
lib/compute/junction.tselegirMotorDeLectura() 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:false porque 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-arithmetic da 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

  1. Row es Record<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.
  2. features es readonly string[], no SqlFeature[]. 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 baratasQUALIFY, 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 Trinolimit-offsetSpark SÍ soporta LIMIT n OFFSET m
sólo Sparktimezone-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 NULLninguna de las 287
spark.sql.legacy.interval.enabledno existe en este build
spark.sql.ansi.enabledtrue — 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 trinospark, blockingForTrinoblockingForSpark, 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

PiezaEstado
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 + HTTPRoutespark-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: mysql2 cierra 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 falta n2-standard-8.

7 · Fuentes