Published

El cutover del catálogo — Lakekeeper ⇒ Gravitino IRC

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

El cutover del catálogo — Lakekeeper ⇒ Gravitino IRC

Approach abierto: 2026-08-13. Propone, no describe. Regla de la casa: cada hecho
lleva el comando que lo demuestra
; lo que no lo lleva va marcado como pendiente (⏳).

Decisión del owner: adoptar el IRC en lugar de Lakekeeper. Este documento mide
primero qué es Lakekeeper para nosotros —no lo que dice su folleto— y después qué
entra en su lugar, qué se gana, qué se pierde y qué hay que traducir.

Encuadre: index-gobernanza-mapa.md §5·octies · §8·1.


1 · La pieza que se sustituye — LAKEKEEPER, medido

npx dotenv -e .env.local -- npx tsx scripts/warehouse/f0-scan-planning.ts

/v1/config → 200 · endpoints declarados: 25 · ⛔ ninguno de plan

1·1 · Los cinco papeles que juega de verdad

Papel¿Lo usamos?
⭐⭐⭐El registro: nombre → puntero de metadataSÍ. Es el núcleo
⭐⭐⭐El CAS del commitdonde vive la atomicidad del lakehouseSÍ. Invariante nº4: «Index nunca serializa escrituras; el CAS es suyo»
⭐⭐El vending de credenciales (…/tables/{t}/credentials), y es el camino de lectura de HOY: Spark pide con X-Iceberg-Access-Delegation: vended-credentials
⭐⭐Warehouses por inquilino — 9 (1 compartido + 8), 7 de 8 ya se sirven del suyo, y es el aislamiento de almacenamiento
OIDC con Keycloak · soft-delete 7 días (P·3) · protección de borrado

1·2 · Y lo que NO usamos, aunque esté en su folleto

OpenFGA inerte · Cedar (no existe en su OSS) · el OPA bridge (es de Trino, que salió) · sts-enabled=false y remote-signing-enabled=false.

Hoy Lakekeeper es, para nosotros, un almacén de metadatos Iceberg con vending. Toda la gobernanza es nuestra — y por eso este cutover no toca la gobernanza.


2 · Lo que entra — EL IRC DE GRAVITINO, medido

kubectl run … curl http://gravitino-irc:9001/iceberg/v1/config (desde el cluster)

24 endpoints · defaults con R2 ya resuelto:
  s3.path-style-access=true · client.region=auto · S3FileIO · el endpoint de R2
Lakekeeper (25)Gravitino IRC (24)
Registro + CAS
…/tables/{t}/credentialsel riesgo que no era
⭐⭐ POST …/tables/{t}/planlo que se gana
POST …/namespaces/{ns}/register✅ ⇒ migrar es re-registrar punteros, no mover bytes
Vistas (CRUD) · rename · metrics
POST /v1/{prefix}/transactions/commitdeclaradoNO — §3
Latencia desde el cómputo222 ms (Railway)5,2 ms (mismo nodo)

3 · ⭐⭐ «No declara transacciones» — qué es y qué se pierde exactamente

POST /v1/{prefix}/transactions/commit es el endpoint de la spec Iceberg REST para el commit ATÓMICO MULTI-TABLA: se mandan los cambios de N tablas en una sola petición y o entran todas o no entra ninguna.

Qué perdemos, dicho con precisión:

Hoy no se usasustrato-07 §7 lo lista como «el catálogo ya lo declara, sin estrenar». Ninguna ruta nuestra lo llama
Pero es la pieza que cierra una deuda ya escrita«⛔⛔ Una escritura toca UNA tabla (writeTarget?: string — la primera de la deuda de escritura
Y su ausencia se nota justo donde vamosun pipeline con dos salidas · un MERGE que reparte · la ingesta como sentencia si escribe más de una tabla · y el split de transformPaths (matched/unmatched), que ya escribe dos datasets y hoy lo hace en dos commits

No perdemos una función: perdemos una OPCIÓN comprada. La diferencia importa
para decidir —no rompe nada hoy— pero registrarla como «no perdemos nada» sería
exactamente el error de comparar folletos al revés.

Y hay que medir antes de cerrar esto: ⏳ ¿lo declara Gravitino 1.3+? La versión desplegada es 1.2.0 porque :latest estaba roto (Jackson). Si una versión posterior lo trae, la pérdida es temporal.


4 · ⭐⭐⭐ El warehouse por inquilino — sí se puede madurar, y así

Medido. Pidiendo un prefijo inventado al IRC:

GET /iceberg/v1/w_aaaa/namespaces
→ 404 NoSuchCatalogException: "Couldn't find Iceberg configuration for catalog w_aaaa"
   at IcebergCatalogWrapperManager.createCatalogWrapper

⇒ ⭐ El IRC SÍ tiene el concepto: el prefijo mapea a un CATÁLOGO CONFIGURADO. No es que sirva uno solo — es que hoy sólo hay uno declarado (GRAVITINO_WAREHOUSE=s3://lakehouse/_probe-gravitino, el de la sonda G·1).

Las dos formas de declararlos, y su diferencia operativa es lo que decide:

CómoProvisionar un inquilino nuevo
estático (IRC standalone)un bloque de config por catálogo⚠️ cambio de manifiesto + redespliegue
por API (servidor Gravitino completo)catálogos del metalake✅ una llamada, como hoy

⚠️ Ésa es la regresión real del cutover, y no está en los endpoints: Lakekeeper crea warehouses por API (/management/v1/warehouse). Con el IRC standalone, dar de alta un inquilino pasa de una llamada HTTP a un despliegue.

Y sin embargo es asumible hoy, por un dato nuestro: /management/v1/warehouse sólo lo llama la ruta de warehouses DEDICADOS, que hoy no usa ningún inquilino — el aprovisionamiento de los 8 se hizo de forma controlada, no por autoservicio. ⇒ La regresión es de frecuencia baja y conocida, y desaparece el día que entre el servidor completo (que es el mismo día que se quieran gobernar tópicos y modelos, §5·septies).


5 · Lo que este cutover NO cambia — y es lo importante

⭐⭐ La gobernanza no se mueve. La cara /api/iceberg sigue delante, autorizando
por inquilino y por tabla; Index sigue siendo la única retícula; la puerta sigue
preguntando «¿puede esta persona?». Lo que se sustituye es el almacén de
metadatos que hay detrás de la cara.

⇒ Y de ahí sale la simplificación más grande del plan: el IRC no necesita OIDC propio. Vive detrás de la cara, en ClusterIP sin ruta externa, y el único que le habla es la cara — que ya autentica al motor y usa su propia credencial aguas arriba. El OIDC de Keycloak que Lakekeeper sí tenía no hay que rehacerlo: hay que no necesitarlo.

⚠️ Con su condición, que es la de siempre: si algún motor puede apuntar al IRC directamente, esto no aplica nada. ClusterIP sin Gateway es hoy lo que lo garantiza, y tiene que seguir siéndolo.


6 · Los gates, en orden de lo que puede tumbar el cutover

QuéGate
🏁 C·0·a⭐⭐⭐ El MECANISMO: el IRC sobre PostgresVERDE 08-13 — §6·bis
🏁 C·0·b⭐⭐ La BASE DURABLE en europe-west1VERDE 08-13 — §6·ter
🏁 C·1Los catálogos por inquilino declarados — 8 + el compartidoVERDE 08-13 — 9/9 + control negativo (§6·quater)
🟡 C·2El vending, EJERCIDO (no sólo declarado)Mecanismo VERDE · paridad ROJA (§6·quinquies · §6·sexies)
C·2·b⭐⭐⭐ ¿Qué vende Lakekeeper? — la pregunta que decide si el cutover es equivalenteMEDIDO 08-13: vende TEMPORAL (~900 s)BLOQUEA el cutover (§6·sexies)
C·3El re-registro: ~82-84 tablas vía POST …/registerLas tablas listadas en el IRC = las de Lakekeeper, y loadTable devuelve el mismo puntero
⚠️ C·4El CAS bajo concurrencia — dos escrituras a la vez sobre la misma tablaUna gana, la otra reintenta. Es el invariante nº4
⚠️ C·5Soft-delete / protección de borrado⏳ comprobar el equivalente; si no existe, decidirlo a propósito
🟡 C·6transactions/commit en 1.3+Si lo declara, la pérdida de §3 es temporal

C·0 antes que nada, y no es una formalidad. El mapa ya lo dejó dicho: «el paso siguiente no es pasar tablas a Gravitino, es decidir dónde vive su base» — y de esa decisión cuelgan la latencia de toda lectura y la única debilidad de escritura (4·4) que la absorción puede cerrar.


6·bis · 🏁 C·0·a — EL IRC SOBRE POSTGRES, MEDIDO (2026-08-13)

infra/gke/c0a-irc-postgres.yaml · Postgres desechable en el namespace, sin gastar un euro: se prueba el MECANISMO antes de provisionar infraestructura de pago.

Los dos bloqueantes, resueltos

⛔→🏁 El driverLa imagen sólo trae sqlite-jdbc-3.42.0.0.jar (medido). Entra sin construir imagen propia: un initContainer con la misma imagen copia libs/ (174 jars) a un volumen, otro baja postgresql-42.7.4.jar al mismo sitio, y el contenedor monta el volumen encima. ⭐ Así el upgrade de versión sigue siendo cambiar un tag, que es lo que se pierde al forkear una imagen
⛔→🏁 La durabilidadDe jdbc:sqlite:/data/irc.db (en emptyDir) a jdbc:postgresql://…. Gravitino crea su esquema solo

El gate: la durabilidad se prueba MATANDO el pod

① POST /namespaces {"namespace":["c0a_durable"]}          → HTTP 200
② POST …/tables    {"name":"t_durable", …}                → metadata.json en R2
                                                            table-uuid 31c35cfd-…
   ── kubectl delete pod -l app=gravitino-irc ──
③ GET  /namespaces                    → [["c0a_durable"]]        ✅ sobrevive
④ GET  …/c0a_durable/tables           → [t_durable]              ✅ sobrevive
⑤ GET  …/tables/t_durable             → MISMO metadata-location
                                         MISMO table-uuid        ✅ sobrevive
   ── limpieza: DELETE tabla (204) + DELETE namespace (204) → namespaces []

El registro es durable y el puntero de metadata se conserva intacto. Con SQLite en emptyDir los tres se habrían perdido.

⚠️ Dos trampas del camino, anotadas porque volverán

  1. curl: (23) client returned ERROR on write — el initContainer del driver corre con uid 100 y el volumen lo había creado root. Un error de PERMISOS cuyo mensaje apunta a la RED. Se resuelve con runAsUser: 0, pero lo que cuesta es creerse el mensaje.
  2. ⛔⛔ HTTP 000 con el pod Ready, la IP correcta y las labels correctas. El Service apunta a targetPort: httpun puerto con NOMBRE— y al reescribir el Deployment declaré containerPort: 9001 sin nombre. El EndpointSlice queda con PORTS <unset> y no llega tráfico.

    ⭐⭐ La lección: reescribir un recurso entero en vez de parchearlo pierde
    detalles invisibles
    , y el nombre de un puerto es el más invisible de todos. El
    síntoma se lee como red o como servidor caído; el servidor estaba perfecto y
    respondía en localhost. Lo que lo desempató fue preguntar por dentro.

    ⚠️ Y kubectl get endpoints miente en k8s 1.33+: devuelve <none> porque el
    recurso legacy ya no se puebla. Lo autoritativo es get endpointslices.

Lo que queda para C·0·b

Cambiar una URL. El mecanismo está probado; lo que falta es la base gestionada —sqladmin.googleapis.com no está habilitada en trino-k8s— y apuntar GRAVITINO_URI a ella. ⚠️ El Postgres de esta prueba usa emptyDir a propósito: prueba el mecanismo, no la durabilidad del almacenamiento.


6·ter · 🏁 C·0·b — LA BASE, CREADA Y PROBADA (2026-08-13)

instancia   carbon-catalog · POSTGRES_16 · europe-west1 · db-f1-micro · 10 GB SSD
red         ⭐ IP PRIVADA 10.99.0.3 (VPC peering sobre `default`, rango 10.99.0.0/20)
            ⇒ sin IP pública, sin Cloud SQL Auth Proxy y sin credencial de servicio:
              el pod habla por la VPC. Menos piezas y menos secretos que gestionar.
backups     diarios 03:00 · storage auto-increase
coste       ~10-15 €/mes

El mismo gate, ahora contra la base gestionada:

① POST /namespaces + ② POST …/tables   → HTTP 200 · table-uuid b857ede1-…
   ── kubectl delete pod -l app=gravitino-irc ──
③④⑤ namespaces · tables · loadTable    → los tres SOBREVIVEN
                                          MISMO table-uuid · MISMO metadata-location
   ── limpieza: 204 / 204 → namespaces []

⚠️ La latencia real, y hay que corregir el «40×»

loadTable (5 medidas)   64 – 86 ms      contra Lakekeeper: ~222 ms

~3× en la operación completa, no 40×. El 40× del mapa era la conexión TCP al catálogo (1,3 ms vs 46 ms) — y sigue siendo cierto. Pero un loadTable lee el metadata.json de R2, y R2 son ~66 ms que no se mueven. La mejora es real y es de 3×; publicar el 40× como si fuera la de la operación sería vender el número que mide la parte, no el todo.

⭐ Y la mejora se multiplica igual: una query de N tablas hace ≥N cargas.


6·quater · 🏁 C·1 — LOS 9 CATÁLOGOS, DECLARADOS (2026-08-13)

⚠️ Primero, el vocabulario — porque aquí se cuela un error de dos niveles

El paradigma NO cambia: sigue siendo un inquilino → un espacio aislado. Lo que
cambia es la pieza que lo implementa, y su nombre:

Lakekeeper      warehouse  carbon-prod-w-<hex>   ─┐
                                                  ├─ mismo papel, distinto nombre
Gravitino IRC   catalog    w_<hex>               ─┘

Y el prefijo de la ruta REST sigue siendo w_<hex>la cara no cambia una
línea.

Pero «catálogo» ya significa otra cosa aquí: catalogs en Index (10 filas) son main, karma… — lo que el usuario ve en catalog.schema.table. Son dos niveles distintos. Llamar «catálogo» a los dos es exactamente cómo se coló la confusión de warehouse (espacio vs inquilino) y la de nombre vs ubicación. En este documento: catálogo-de-inquilino para el de Gravitino, catálogo lógico para el del usuario.

Lo medido antes de declarar — y no eran 9

Lakekeeper   11 warehouses   (8 carbon-prod-w-* · 2 carbon-test-w-* · 1 lakehouse)
Index         8 tenant_warehouses
Hallazgo
⚠️Son 11, no 9. Los dos carbon-test-w-* son la deriva ya censada (vivos en Lakekeeper, no en Index)
⚠️Dos inquilinos tienen su key-prefix en test/, no en prod/ — la deriva del perímetro
⛔⛔El inquilino donde está TODO el dato (w_7b500f4d…) tiene dedicated=falsela cara le sirve el COMPARTIDO. Su warehouse propio existe y está activo sin motivo

⇒ Los «9» son 8 inquilinos + el compartido, y el compartido es el que importa.

Cómo se declararon

Bloques gravitino.iceberg-rest.catalog.<nombre>.* (13 claves × 9 = 117 líneas), cada uno con su key-prefix REAL de Lakekeeper — incluidos los dos en test/: la deriva se limpia en su propio paso, no de tapadillo en éste.

Los 9 comparten la misma base: el JdbcCatalog de Iceberg separa por catalog_name, así que una sola instancia sirve a los nueve.

⚠️ Las credenciales van en un Secret, nunca en el repo ni en un ConfigMap — cada bloque lleva las de R2 y las de la BD. Y el conf/ se compone en un initContainer: copiar el directorio entero + añadir los bloques, porque montar el Secret encima taparía los otros ficheros, y el conf tiene que quedar escribible (rewrite_config.py lo borra y lo reescribe).

Lo que hace que sobreviva: rewrite_config.py parsea el conf y sólo actualiza las claves de su env_map — las catalog.<name>.* que no conoce las conserva. Verificado leyendo el script, no supuesto.

El gate

  OK 200  lakehouse                            → {"namespaces":[]}
  OK 200  w_cccc70a5…  w_7b500f4d…  w_ec14e3cc…  w_0936227f…
  OK 200  w_5b3dba28…  w_cbe70f74…  w_17c5cc10…  w_1548b5ae…
  ⇒ 9/9

  control negativo:  w_noexiste → HTTP 404      ✅ no acepta cualquier prefijo

Los nueve están vacíos: el registro de las tablas es C·3.


6·quinquies · 🏁 C·2 — EL VENDING, EJERCIDO (2026-08-13)

infra/gke/c2-vending-gate.yaml · Spark contra el IRC del cluster, sin pasar por la cara ni por Lakekeeper.

⛔ Primero salió ROJO, y ése es el hallazgo

IllegalArgumentException: There are no credential provider for the catalog.

El IRC DECLARA …/tables/{t}/credentials y no vendía nada. El endpoint estaba en endpoints[] —que es lo que hizo que el cutover pareciera viable en §2— y declarar no es servir. Si el cambio se hubiera hecho leyendo sólo la lista de endpoints, el camino de lectura entero se habría caído el día del cutover.

⭐⭐ Es la regla 8·ter otra vez, y ahora del lado de lo que vamos a adoptar: antes de
contar una capacidad, buscar el comando que demuestra que está encendida.
Un
endpoints[] es un folleto muy bien escrito.

🏁 Con credential-providers configurado

GATE 1  catalogo=w_7b500f4d… · namespaces=[]
GATE 2  ⭐ CTAS OK — el IRC acepta la escritura
GATE 3  ⭐⭐ LECTURA OK — 50 filas · Row(id=0, triple=0, etiqueta='v-0')
GATE 4  fichero: s3://lakehouse/test/w_7b500f4d…/c2_probe/c2_vending/data/00000-0-….parquet
GATE 5  limpieza hecha

Y el fichero aterrizó bajo el key-prefix declarado en C·1 ⇒ el catálogo-de-inquilino dirige los bytes a su espacio: el aislamiento de almacenamiento sobrevive al cambio de pieza.

⚠️⚠️ La salvedad, que es un bloqueante ANTES del cutover real

El proveedor que funciona con R2 es s3-secret-key: entrega la llave LARGA, no una credencial temporal. s3-token (STS) no sirve — R2 no habla STS de AWS, que es la misma razón por la que Polaris se descartó.

Y eso convierte en bloqueante una pregunta que este mapa ya tenía abierta desde el 08-12 (§8·7·①): «la afirmación hoy vendamos credencial STS NO está medida»tenant-warehouse.ts:125-126 pone sts-enabled: false, pero esa ruta sólo corre para warehouses dedicados y el compartido —que es el que sirve— se configuró fuera de ese código.

Si Lakekeeper HOY entrega la llave larga  ⇒ `s3-secret-key` es EQUIVALENTE. Sin cambio.
Si Lakekeeper HOY vende STS              ⇒ el cutover es una REGRESIÓN de seguridad.

[medir] — y ahora no es una curiosidad: decide si el cutover se puede hacer tal cual. Un GET …/credentials contra el warehouse compartido de Lakekeeper lo contesta.


6·sexies · ⛔⛔ C·2·b — QUÉ VENDE LAKEKEEPER, MEDIDO (2026-08-13)

scripts/warehouse/c2b-que-vende-lakekeeper.ts · sólo lee, y está escrita para mirar credenciales sin enseñarlas: imprime las claves y la forma del valor (longitud, 4 caracteres iniciales), nunca el valor. Ya se filtró una credencial en esta plataforma por volcar un fichero entero.

tabla de prueba: datasets.ds_toydemo001  ·  warehouse compartido `lakehouse`

loadTable con X-Iceberg-Access-Delegation → 200
  s3.access-key-id · s3.secret-access-key · ⭐ s3.session-token (692 chars)
storage-credentials → 1 entrada
  ⭐ expiration-time · s3.session-token-expires-at-ms
  ⭐ client.refresh-credentials-endpoint          ← hasta el endpoint de refresco
GET …/tables/{t}/credentials → 200
  ⏱️ caduca en 855 s  (~14 min)

⇒ El veredicto, y es el contrario del que convenía

⛔⛔ Lakekeeper vende credenciales TEMPORALES de ~900 s, con token de sesión y
endpoint de refresco.
El cutover con credential-providers = s3-secret-key
cambiaría una llave que caduca en 15 minutos por la llave PERMANENTE de R2.
Eso es una regresión de seguridad real, no un matiz.

⚠️ Y una corrección a lo que yo mismo escribí hace un rato

Dije «R2 no habla STS» como si fuera la causa. Lo medido lo desmiente: R2 sí entrega credenciales temporales, y Lakekeeper sabe pedírselas. La limitación es de Gravitino, cuyo proveedor s3-token va por AssumeRole de AWS STS — una vía que R2 no expone. No es que el almacenamiento no pueda: es que el catálogo nuevo no sabe.

🏁 Y de paso cierra una pregunta abierta desde el 08-12

index-gobernanza-mapa.md §8·7·① decía: «la afirmación hoy vendamos credencial STS (900 s) NO está medida … hay que medirla antes de construir nada encima, porque el paso 5 entero cuelga de ella».

Está medida, y era cierta: ~855 s. Con lo que también queda confirmado, ahora con comando, el §5·3 del mapa: mientras se venda credencial para leer el Parquet directo, cualquier política de fila es decorativa.

Las salidas, sin maquillar

SalidaCoste
AParar el cutover aquí. C·0/C·1 no se tiran: el IRC queda en pie, durable y con sus 9 catálogos, esperando0. Es la posición actual
BContribuir a Gravitino un credential provider para las temporales de R2Desarrollo en un proyecto ajeno, con su ritmo de release
CQue el vending lo sirva LA CARA y no el catálogo — ella ya está delante y sabe quién preguntaNuestro, y encaja con el norte… pero es construir el vending
⭐⭐ DRemote signing en vez de vending — el catálogo firma cada petición y no entrega llave ningunaLatencia por fichero. ⭐ Y es justo lo que el mapa §8·7·② proponía para conmutar el vendado, que es el paso que hoy bloquea la política de celda

⭐⭐⭐ D convierte un bloqueante en una oportunidad: el paso que hace falta para
migrar de catálogo es el mismo que hace falta para que la política de fila deje de
ser decorativa. No son dos obras: es una.

C·3 NO se ejecuta hasta resolver esto. Re-registrar 82 tablas contra un catálogo que sólo sabe entregar la llave larga sería consolidar la regresión.


6·septies · ⭐⭐⭐ SALIDA B — el provider de R2 para Gravitino: el patrón, calcado

Decisión del owner: B. Y bien planteada: «investiga si ya se ha hecho y calcamos
el patrón»
. Se ha hecho, y las dos mitades están documentadas.

① Lo que hace Lakekeeper — la referencia

Lakekeeper soporta R2 con vended credentials vía
/accounts/{account_id}/r2/temp-access-credentials.

No es magia ni STS de AWS: es la API de credenciales temporales de Cloudflare. Y es Apache-2.0 y Rust, así que su implementación se puede leer.

② Lo que ofrece Gravitino — el punto de extensión, documentado

interfaz   org.apache.gravitino.credential.CredentialProvider
registro   `credential-providers = <nombre>`  (lista separada por comas)
jar        al CLASSPATH del Iceberg REST server
ya existe  S3TokenCredential  (accessKeyId · secretAccessKey · sessionToken · expiration)

⭐⭐ Y el jar no es un problema para nosotros: ya sabemos meterlo. Es exactamente el mecanismo de C·0·a — el initContainer que compone libs/. No hay que forkear la imagen ni esperar a un release de Gravitino: es su punto de extensión.

Eso rebaja B de «contribuir a un proyecto ajeno, con su ritmo» a «un jar nuestro en un directorio que ya montamos».

③ ⭐⭐ Y hay DOS vías para obtener la temporal — la elección no es indiferente

Cloudflare documenta las dos:

A · la API (lo que hace Lakekeeper)B · el JWT local
CómoPOST …/r2/temp-access-credentials con el parent tokenFirmar un JWT (HS256) con el parent secret; el secreto temporal es el SHA-256 hex del JWT, y el session token es base64("jwt/" + jwt)
Red⚠️ una llamada externa por vendingCERO: se computa
Permiso que exige⚠️⚠️ «Admin Read & Write» de la CUENTAel parent secret access key que ya tenemos
LatenciaRTT en cada loadTable con delegaciónninguna

⛔⛔ Y ahí hay un hallazgo de seguridad que no buscábamos: la API de temporales
exige un token «Admin Read & Write» de la cuenta de Cloudflare. ⇒ Si Lakekeeper la
usa —y su documentación dice que sí—, nuestro catálogo actual tiene hoy un token
Admin de la cuenta
. Eso es un permiso mayor que la llave larga del bucket que
tanto nos preocupaba. [verificar contra nuestro despliegue]

⇒ ⭐ Calcamos el patrón en su FORMA, no en su MECANISMO: vended temporal devuelto como S3TokenCredential —igual que Lakekeeper— pero obtenido por la vía JWT local. Es la regla 46 de la casa: se toma el modelo, se deriva en nuestro sustrato. Y aquí la derivación es estrictamente mejor: sin latencia añadida al camino de lectura —que es el argumento de todo el cutover— y sin darle a un catálogo un token de administración de la cuenta.

④ El plan, con gates

QuéGateEstado
🏁B·0 · ⭐ Reproducir la vía JWT a mano — acuñar una temporal y leer un objeto de R2 con ellaUn GET a un objeto real con X-Amz-Security-Token, y un control negativo: la misma credencial caducada debe fallarVERDE (§6·octies)
🏁B·1 · El CredentialProvider (Java) que la devuelve como S3TokenCredentialTest unitario del cómputo: mismo JWT ⇒ mismo secreto derivadoVERDE (§6·nonies)
🏁B·2 · El jar al classpath del IRC (initContainer, como C·0·a) y credential-providers = r2-token en los 9El IRC arranca y no dice «no credential provider»VERDE (§6·decies)
🏁B·3 · ⭐⭐ Re-correr C·2 — Spark escribe y lee con vended-credentialsCTAS + lectura verdes y la credencial con expirationVERDE (§6·undecies)
🏁B·4 · Paridad con LakekeeperVentana comparable (~900 s) y session-token presente ⇒ el cutover deja de ser regresiónVERDE — 900 s vs 855 s, y además acotada por prefijo

⛔⛔⛔ Pero el cutover NO queda desbloqueado: lo bloquea ahora otra cosa. B·3 destapó que los 9 catálogos-de-inquilino comparten espacio de metadatos§6·duodecies, y C·3 sigue congelado por un motivo distinto y peor que el del vending.

⚠️ B·0 antes que escribir una línea de Java. Si el cómputo del JWT no produce una credencial que R2 acepte, todo lo demás sobra — y es media hora con curl, no un proyecto.


6·octies · 🏁 B·0 — MEDIDO: R2 acepta la temporal que acuñamos en casa

Sondas: scripts/warehouse/b0-jwt-r2-temporal.ts (la escalera) ·
scripts/warehouse/b0b-que-vende-cloudflare.ts (el patrón contra el que medir).
Contra el bucket lakehouse real, sin cluster. npx dotenv -e .env.local -- npx tsx …

① El veredicto, gate a gate

0     la llave larga lista el bucket                          200 · 5 prefijos
0·b   temporal de CLOUDFLARE + nuestro SigV4                   200        ⇐ el desempate
①     local, claims MÍNIMOS { bucket, scope }                  200
①·b   local + paths.prefixPaths: ["_probe-gravitino/"]         200
        · el MISMO token contra un objeto FUERA del prefijo    403 AccessDenied   ⭐⭐
①·c   local + actions: ["GetObject","HeadObject"]  (¡la doc!)  400 InvalidArgument ⛔
②     la MISMA credencial, CADUCADA                            403               ⭐
③     PUT con scope object-read-only                           403 AccessDenied

⇒ ⭐⭐⭐ La vía JWT local es viable, y acota. No sólo autentica: el prefixPaths recorta de verdad — misma credencial, un objeto fuera de su prefijo, AccessDenied. Eso es exactamente lo que el vending por-inquilino necesita, computado sin una sola llamada de red. B·1 tiene suelo.

② ⭐⭐ Y de paso, el hallazgo de §6·septies③ queda CONTESTADO

POST /accounts/{id}/r2/temp-access-credentials con nuestro CLOUDFLARE_API_TOKEN (Workers R2 Storage: Edit, nivel cuenta) devolvió 200. ⇒ La API no exige un token «Admin Read & Write» de la cuenta como temíamos.

⚠️ Pero eso no reabre la vía A: sigue costando un RTT por vending en el camino de lectura —que es el argumento entero del cutover— y sigue exigiendo que el catálogo tenga un token de la API de Cloudflare, no sólo una llave de bucket. La vía B gana por lo mismo que ganaba antes; lo que cae es sólo el argumento del permiso.

③ 🪤 Lo que la doc oficial dice y R2 no cumple

El ejemplo publicado por Cloudflare incluye actions: ["GetObject","HeadObject"]. R2 devuelve 400 InvalidArgument con cualquier forma del claim — probadas ["GetObject","HeadObject"], ["s3:GetObject",…], ["GetObject"] y hasta []. Y no es que rechace claims desconocidos: un claim inventado (objects) pasa con 200.

actions se parsea y se rechaza. Para B·1 no se usa: scope (lectura/escritura)

  • paths.prefixPaths (el ámbito) ya cubren lo que el vending necesita.

④ 🪤 La trampa de medición — la primera pasada dio TRES verdes falsos

La versión inicial mandó los claims completos. R2 contestó 400 InvalidArgument a las cuatro pruebas… y como los tres controles negativos sólo miraban «¿no fue 200?», salieron verdes. Un token malformado se rechaza igual que uno caducado.

⇒ Dos correcciones, y las dos son de método:

  • Leer el <Code> de S3, no el «no-200». InvalidArgument (400, forma) y AccessDenied (403, permiso) son respuestas de dos preguntas distintas.
  • Un peldaño intermedio que separa las hipótesis: la temporal de Cloudflare firmada con nuestro SigV4 (0·b). Con ese 200 en la mano, todo fallo posterior es de los claims y no de la firma. Sin él habríamos ido a depurar el firmador.

⚠️ Y una menor, para no buscar el string equivocado: la credencial caducada devuelve SignatureDoesNotMatch, no ExpiredToken — R2 deriva el secreto del JWT, así que un JWT que ya no vale se manifiesta como una firma que no cuadra. Lo que hace válido el control es que la única variable entre ① y ② es iat/exp.


6·nonies · 🏁 B·1 — el provider existe, y el gate corre dentro del build

Entregable: infra/gravitino-r2-credential/
— dos clases, cero dependencias de runtime, jar de ~8 KB.
docker build --target gate .

① Lo comprobado — 20/20 en frío, sin cluster y sin red

determinismomismas entradas ⇒ mismo JWT ⇒ mismo secreto · control negativo: otro iat ⇒ otro secreto
⭐⭐ vector doradoel mismo token byte a byte que la sonda TS de B·0
⭐⭐⭐ la traduccióntoIcebergPropertiess3.access-key-id · s3.secret-access-key · s3.session-token
el ámbitolectura/escritura y prefijo salen del contexto; rechaza no acotar y rechaza dos buckets
el registrocenso por ServiceLoader sobre el classpath real

Se compila contra la imagen que se despliega, no contra Maven Central: el classpath del build es el libs/ que monta el pod ⇒ «compila» y «carga en el pod» son la misma afirmación, y no dependemos de que Apache publique los artefactos.

El gate va DENTRO del build: un jar que no pasa B·1 no llega a existir.

② ⭐⭐⭐ El hallazgo que habría costado un día: la traducción despacha por CLASE

CredentialPropertyUtils.toIcebergProperties decide con instanceof de la clase concreta, no con el string de credentialType(). ⇒ Si el provider devolviera una Credential nuestra, caería al else y el cliente recibiría s3-session-token —el nombre interno de Gravitino— en vez de s3.session-token. El IRC arrancaría, vendería, y el motor sencillamente no encontraría la credencial. Ni un error ni un log: un 403 al leer.

La regla del calco, afilada: se copia la FORMA (S3TokenCredential de serie) y se deriva el MECANISMO (nuestro cómputo). El tipo, en cambio, tiene que ser nuestro:

censo del classpath real (ServiceLoader, 174 jars):
  adls-token · aws-irsa · azure-account-key · gcs-token · oss-secret-key
  oss-token · s3-secret-key · s3-token            ⇐ OCUPADO por bundles:aws

CredentialProviderFactory revienta con «Multiple credential providers found» si dos declaran el mismo tipo ⇒ el nuestro es r2-token.

③ ⭐ Y no hace falta ningún secreto nuevo

El provider lee s3-endpoint · s3-access-key-id · s3-secret-access-keylas que el .conf ya tiene, que son exactamente el parent con el que se firma. La cuenta se deduce del host del endpoint. Una pieza menos que rotar, y un secreto menos que filtrar.

④ 🪤 Otra trampa de censo, de la misma familia que la de B·0

Para contestar «¿está s3-token ocupado?» se barrió la imagen con unzip sobre los 174 jars: cero resultados. La imagen no trae unzip, y el 2>/dev/null se tragó el error ⇒ la lectura natural habría sido «no hay ningún provider registrado», que es exactamente lo contrario de la verdad.

⇒ Se rehízo con el mismo mecanismo que usa Gravitino (ServiceLoader sobre el classpath real), y el censo vive ahora dentro del gate, donde se vuelve a correr en cada build.

⑤ ⚠️ Lo que B·1 no prueba

Que R2 acepte en vivo la credencial acuñada por el Java. Lo cubre por transitividad —el JWT es idéntico byte a byte al de B·0 y el secreto es función pura de esa cadena—, pero el 200 con bytes reales lo da B·3.


6·decies · 🏁 B·2 — el IRC ya vende TEMPORAL, y acotada

Entregables: infra/gke/b2-r2-credential-provider.yaml
(supersede el Deployment de C·0·a) · scripts/warehouse/b2-vending-temporal.ts (el gate).

① Lo medido, contra el IRC desplegado

initContainers   jar en libs/ (7.895 B) · «9 catalogos tenian provider, 9 quedan en r2-token»

loadTable con  X-Iceberg-Access-Delegation: vended-credentials
  s3.session-token  ✅ presente        ⇒ TEMPORAL, no la llave larga
  el token decodifica a `jwt/…`        ⇒ es la NUESTRA, vía JWT local
  scope        object-read-write
  prefixPaths  ["_probe-gravitino/_b2_probe/sonda_…/"]   ⇐ ⭐ ACOTADA a ESTA tabla
  ventana      900 s                   ⇒ paridad con los ~855 s de Lakekeeper
control negativo · el MISMO loadTable SIN la cabecera → ninguna clave de credencial

⇒ ⭐⭐⭐ El bloqueo C·2·b está resuelto. El cutover deja de ser una regresión de seguridad: donde Lakekeeper vende temporal, el IRC ahora vende temporal y además acota por prefijo, que es algo que la llave larga no hacía.

② ⭐ Dos decisiones de despliegue

El volteo se hace en el initContainer, no en el Secret. Los 9 catálogos viven en irc-catalogs, que lleva credenciales en claro; editarlo obliga a sacarlo, cambiarlo y volver a meterlo — tres ocasiones de que se quede en un historial. El sed corre dentro del pod sobre el fichero ya montado: ninguna credencial sale, y el cambio queda en git y a la vista en vez de escondido en un valor de Secret que nadie puede revisar.

Y sustituye en sitio en vez de añadir una línea duplicada al final: apoyarse en «la última clave gana» sería apostar por un detalle del parser que nadie ha medido. Además el initContainer comprueba el invariante antes de arrancar —tantos r2-token como catálogos tenían provider— así que un cambio de forma del fichero falla ruidosamente en vez de degradar en silencio a la llave larga.

El jar llega por ConfigMap (7,9 KB; el límite es 1 MB) y se copia a libs/ con el mismo mecanismo del driver de Postgres de C·0·a: no se forkea la imagen ⇒ subir de versión sigue siendo cambiar un tag.

③ ⚠️ El límite honesto: el read/write NO lo decidimos nosotros

Un loadTable de sólo lectura recibió scope: object-read-write. No es un fallo del provider: el privilegio le llega ya decidido desde arriba.

// IcebergTableOperationExecutor.getCredentialPrivilege
boolean writable = MetadataAuthzHelper.checkAccess(identifier, TABLE, FILTER_MODIFY_TABLE);
return writable ? CredentialPrivilege.WRITE : CredentialPrivilege.READ;

El eje lectura/escritura del vending lo decide el control de autorización de Gravitino, que en el IRC suelto no está cableado y responde que sí a todo. Encaja con §4·2: el catálogo administra, no aplica. Lo que B·2 aporta es real —temporal + prefijo— pero la mitad read/write del ámbito sigue esperando a que alguien diga que no, y ese alguien es la puerta.

④ 🪤 Trampas de esta tanda

  • ⛔⛔ kubectl port-forward deploy/… durante un rollout se engancha al pod que aún TERMINA. Un loadTable devolvió «There are no credential provider» con la configuración correcta ya en el pod nuevo, y la lectura natural —«la clave no llega al catálogo por defecto»— era falsa: ambas rutas venden. rollout status en verde no garantiza que el viejo haya muerto.
  • ⚠️ Los 9 catálogos-de-inquilino están VACÍOS (C·3 congelado) ⇒ no hay loadTable que pedir y la sonda tiene que crearse su tabla. Y no vale el catálogo lakehouse: apunta a s3://lakehouse/warehouse, el prefijo de producción. El área de sondas es _probe-gravitino, el catálogo por defecto.
  • ⚠️ El catálogo por defecto no tenía provider ninguno — sólo se habían tocado las claves catalog.<nombre>.*. Añadida gravitino.iceberg-rest.credential-providers.

6·undecies · 🏁 B·3 — el motor escribe y lee con la temporal

Entregable: infra/gke/b3-vending-e2e.yaml.
Es C·2 re-corrido, con el mismo motor y el mismo camino, más lo que C·2 no podía afirmar.
No necesita carbon-connect ni spark-bridge: Spark corre local[1] en el Job.

① catalogo=w_7b500f4d… · ② CTAS OK · ③ LECTURA OK — 50 filas
   fichero: s3://lakehouse/test/w_7b500f4d…/b3_probe/b3_vending/data/00000-….parquet

④ la credencial que el IRC entrega para ESA tabla
   s3.session-token ✅ · decodifica a `jwt/…` ⇒ es la nuestra
   scope        object-read-write
   prefixPaths  ['test/w_7b500f4d…/b3_probe/b3_vending/']
   ventana      900 s (restaban 888 s) · secreto de 64 hex, no la llave larga
⑤ el MISMO loadTable SIN la cabecera → ninguna clave de credencial

⇒ ⭐⭐⭐ C·2 vuelve a estar verde, pero ahora el verde vale: aquel pasó con s3-secret-key —la llave permanente—, y éste pasa con una temporal de 900 s acotada al prefijo de la tabla. La regresión de seguridad del cutover está cerrada.

⚠️ La pregunta se hace con el MOTOR y no con curl por una razón: la credencial no la consume quien la pide, la consume el S3FileIO que va a por los bytes. B·2 midió lo que el IRC entrega; B·3 mide que el motor puede usarlo.


6·duodecies · ⛔⛔⛔ HALLAZGO QUE BLOQUEA C·3: los 9 catálogos NO están aislados

Encontrado al leer el GATE 1 de B·3: apareció un namespace _b2_probe dentro del
catálogo-de-inquilino… y ese namespace se había creado por la ruta sin prefijo.

① Lo medido

namespaces, preguntando por cada ruta:
  (sin prefijo)        {"namespaces":[["_b2_probe"]]}
  lakehouse/            {"namespaces":[["_b2_probe"]]}
  w_7b500f4d…/          {"namespaces":[["_b2_probe"]]}      ⇐ creado UNA vez,
  w_0936227f…/          {"namespaces":[["_b2_probe"]]}         visible en TODOS
  w_1548b5ae…/          {"namespaces":[["_b2_probe"]]}

Y la fuga llega a las tablas, no sólo a los namespaces:

createTable en el inquilino A          → 200 · s3://lakehouse/test/w_7b500f4d…/…/tabla_de_A
listTables  POR EL PREFIJO DE B        → 200 · [{"namespace":["_fuga_probe"],"name":"tabla_de_A"}]
loadTable   POR EL PREFIJO DE B        → 200 · location = la de A
loadTable   POR EL PREFIJO DE B, con `vended-credentials`:
      scope        object-read-write
      prefixPaths  ['test/w_7b500f4d…/_fuga_probe/tabla_de_A/']   ⇐ ⛔⛔ EL PREFIJO DE A

Un inquilino puede listar, cargar y obtener credencial read-write sobre los bytes de otro. No es un fallo del vending: el provider acota correctamente a la ubicación de la tabla que le piden; el problema es que B no debería poder pedir esa tabla.

② Qué queda en pie de C·1, y qué no

C·1 declaró «9 catálogos-de-inquilino, 9/9 en 200, con control negativo (w_noexiste → 404)». Eso sigue siendo cierto y sigue midiendo algo real: el ENRUTADO. Lo que no midió —y se dio por hecho— es el aislamiento: la nota «el JdbcCatalog de Iceberg separa por catalog_name» es falsa en este despliegue.

⇒ El prefijo w_<hex> selecciona dónde aterrizan los bytes nuevos (cada catálogo tiene su warehouse), no un espacio de metadatos propio. Hoy sólo aísla el key-prefix.

③ ⛔ Consecuencia inmediata

C·3 —el re-registro de las ~82 tablas— NO se ejecuta. Meter el dato real de 8 inquilinos en un espacio de metadatos compartido convierte un problema de configuración en una exposición entre inquilinos, con las tablas de verdad dentro.

⚠️ Alcance: el IRC es ClusterIP inerte y la cara sigue sirviendo por Lakekeeper ⇒ hoy no hay exposición en producción. Es un bloqueante del cutover, no un incidente.

Sonda: infra/gke/b3b-censo-catalog-name.yaml (sólo SELECT, contra carbon-catalog por la VPC). Con una tabla creada en el inquilino A y otra en el B, por sus prefijos respectivos:

SELECT catalog_name, count(*) FROM iceberg_tables GROUP BY 1;
 catalog_name | tablas          SELECT catalog_name, table_namespace, count(*) …
--------------+--------          catalog_name | table_namespace | count
 jdbc         |      2          --------------+-----------------+-------
(1 row)                          jdbc         | ns_de_A         |     1
                                 jdbc         | ns_de_B         |     1

⇒ ⭐⭐⭐ catalog_name = 'jdbc' para los nueve. Y jdbc no es el nombre de ningún catálogo: es el tipo de backend. Los 9 JdbcCatalog se inicializaron con el valor por defecto, así que escriben todos en la misma partición de iceberg_tables.

Y es exactamente el mecanismo que Iceberg reserva para aislar. De la propia doc de Gravitino:

gravitino.iceberg-rest.catalog-backend-name«The catalog backend name passed to
underlying Iceberg catalog backend. Catalog name in JDBC backend is used to isolate
namespace and tables
»
· por defecto jdbc para backend JDBC · desde 0.5.2

El arreglo es declarar el nombre, uno por catálogo:

gravitino.iceberg-rest.catalog.<nombre>.catalog-backend-name = <nombre>

⚠️⚠️ Y el momento importa: hay que hacerlo AHORA, antes de C·3. Cambiar el catalog_name de un catálogo con contenido deja huérfanas sus filas en iceberg_tables — el catálogo dejaría de ver sus propias tablas. Hoy los 9 están vacíos, así que el cambio es gratis; después de re-registrar 82 tablas, no.

Gate del arreglo: el control CRUZADO, no un 200 por catálogo — crear en A y comprobar que B no la ve (regla 87).


6·terdecies · 🏁 C·1·b — el aislamiento, ARREGLADO y comprobado en cruz

Arreglo: una línea por catálogo, derivada del propio fichero en el initContainer de b2-r2-credential-provider.yaml:

gravitino.iceberg-rest.catalog.<nombre>.catalog-backend-name = <nombre>
compose-conf:  credential-providers: 9 catalogos tenian provider, 9 quedan en r2-token
               catalog-backend-name: 9 catalogos, 9 nombres propios puestos

Gate: scripts/warehouse/c1b-aislamiento-cruzado.tsel control CRUZADO, no un 200 por catálogo:

① createTable en A                                          200 · s3://…/test/w_7b50…/…
② control positivo · A ve lo suyo (list + load)             ✅  ⇐ el arreglo no rompió A
③ ⭐ listTables POR EL PREFIJO DE B                          404 · el ns ni existe para B
   ⭐ loadTable  POR EL PREFIJO DE B                         404
④ ⭐ B pide delegación sobre la tabla de A                    404 · sin credencial
⑤ el namespace de A no aparece en el listado de B            B ve: []

⇒ ⭐⭐⭐ Los catálogos-de-inquilino están aislados. Lo que C·1 midió era el enrutado; esto mide la separación. El bloqueante de §6·duodecies queda cerrado.

⚠️ Los nombres se derivan del propio fichero de catálogos, así que un inquilino nuevo queda aislado sin volver a tocar el manifiesto.


6·quaterdecies · ⏭️ «Nueva organización = nuevo catálogo» — el paradigma es correcto, pero hoy no es nativo

① Lo que pasa HOY al dar de alta una organización

lib/storage/tenant-provisioning.ts — un worker del control plane que atiende el INSERT en workspaces (webhook de Clerk) y produce dos artefactos: el ESPACIO (warehouse + prefijo) y los PERMISOS (cadena de roles).

Ese espacio se crea en LAKEKEEPER (provisionTenantWarehouseLAKEHOUSE_REST_URI). Nada crea un catálogo del IRC. Los 9 que existen están escritos a mano en el Secret irc-catalogs, que el IRC lee con el StaticIcebergConfigProvider.

⇒ Con el despliegue de hoy, una organización nueva exige editar un Secret y reiniciar el IRC. Eso no es nativo y no escala: es exactamente el «alta de warehouse por API» que §4 daba por perdido en el cambio de catálogo.

② ⭐⭐⭐ La salida existe, y es el mismo patrón que ya hemos usado dos veces

IcebergConfigProviderFactory no tiene una lista cerrada:

String className = ICEBERG_CATALOG_CONFIG_PROVIDER_NAMES.getOrDefault(providerName, providerName);
Class<?> providerClz = Class.forName(className);          // ⇐ admite una clase NUESTRA

Y la resolución no es de arranque: el IcebergCatalogWrapperManager pide el catálogo por nombre y de forma perezosa, con caché por acceso:

catalogWrapperCache.get(catalogName, k -> createCatalogWrapper(catalogName));   // expireAfterAccess
   └─ configProvider.getIcebergCatalogConfig(catalogName)

Un IcebergConfigProvider propio que lea los catálogos de INDEX resuelve el paradigma de verdad: dar de alta una organización pasa a ser una fila, y el primer loadTable de w_<nuevo> lo materializa sin reiniciar nada. Encaja con el norte —Index es la autoridad, el catálogo registra— y el jar entra por el libs/ que ya montamos.

③ ⭐⭐⭐ El estándar del sector — y anula la idea de escribir un provider propio

La pregunta se llevó a «¿cuál es el patrón más limpio?», y los cuatro coinciden:

Cómo nace un catálogo
Lakekeeperfila en SU base, por su Management API
Polarisentidad en SU metastore, por su API de administración
Unity Catalogfila en SU base, por API
Gravitino (servidor)entidad en SU almacén, por POST /api/metalakes/{m}/catalogs

El servicio de catálogo es DUEÑO de su registro de inquilinos y se muta por su
Management API. El control plane LLAMA al catálogo; el catálogo NUNCA lee al control
plane.

⭐ Y ya lo hacíamos así con Lakekeeper: provisionTenantWarehouse llama a su API. Lo desviado era desplegar el IRC suelto, que sin servidor detrás sólo sabe leer configuración estática — de ahí los 9 catálogos a mano en un Secret.

⇒ ⛔ Un IcebergConfigProvider propio que leyera Index habría sido el rodeo: metía una credencial del control plane en el plano de datos, duplicaba la regla de nombrado y ataba el arranque del catálogo a la cara. Nada de eso hace falta.


6·quindecies · 🏁 D·0…D·3 — el paradigma nativo, montado y medido

① Lo desplegado

D·0d0-gravitino-server.yamlEl servidor completo, almacén relacional sobre carbon-catalog. ⭐ Sin initContainers: la imagen ya trae postgresql-42.7.0.jar y compone su conf por variables de entorno
D·0·bd0b-gravitino-schema.yamlSu esquema (schema-1.2.0-postgresql.sql, sacado de la propia imagen): 2 tablas → 35
D·1d1-catalogos-por-api.yamlLos 9, de config estática a entidades por API. 9 creados · 0 fallos
D·1·bd1b-forzar-provider.yamlcredential-providers = r2-token forzado en los 9
D·2d2-irc-dinamico.yamlEl IRC pasa a dynamic-config-provider y se suprime el camino estático (catalogos ESTATICOS en el conf: 0)
D·3d3-alta-sin-reinicio.yamlEl gate

② 🏁 D·3 — el gate, verde

① el catalogo se crea POR API                                    200
② el IRC crea un namespace en el catalogo RECIEN nacido          200   ⇐ SIN reiniciarlo
   y crea una tabla dentro                                       200
   los bytes van al prefijo NUEVO   s3://…/test/w_d3a2…/_d3_probe/sonda
③ vende credencial TEMPORAL en el catalogo nuevo                 200 · s3.session-token
④ el catalogo VECINO no ve la tabla del nuevo                    404
   ni la puede cargar                                            404

⇒ ⭐⭐⭐ Organización nueva = catálogo nuevo, servido sin reiniciar nada, vendiendo temporal acotada y aislado del vecino. Y verificado además que los 9 existentes siguen vendiendo temporal (s3.session-token) y que C·1·b sigue verde.

③ ⏭️ Lo que falta para cerrar el círculo

El paradigma está montado; falta el último metro, que es del control plane:

  1. Exponer el servidor Gravitino con autenticación. Hoy es ClusterIP y gravitino.authorization.enable está sin cablear ⇒ Vercel no lo alcanza, y no debe alcanzarlo sin auth.
  2. tenant-provisioning.ts llama a POST /api/metalakes/carbon/catalogs además de a Lakekeeper — escritura doble deliberada mientras dure la transición, para que ninguna organización nacida en el intervalo se quede fuera del catálogo nuevo.

⇒ Ambos van con la migración del SQL Editor, que es cuando el IRC deja de ser inerte.

④ ⭐⭐⭐ ¿QUÉ hace el servidor, exactamente? — medido apagándolo

Pregunta del owner: «¿el servidor completo? ¿no usábamos sólo el IRC, el equivalente a
Lakekeeper? CAS, atomicidad, /plan…»
. Se contesta apagándolo, no explicándolo.

gravitino-server a 0 réplicas:

  catálogo YA en caché     loadTable            200
                           loadTable + vending  200 · s3.session-token ✅
                           createTable (commit) 200      ⇐ CAS y atomicidad, INTACTOS
  catálogo con caché FRÍA  listar namespaces    500      ⇐ aquí sí hace falta

El camino de datos es del IRC al 100%. El servidor no está en el camino de la petición: sólo contesta «qué catálogos existen y con qué propiedades», y sólo cuando el IRC no lo tiene cacheado. /plan, el commit, el CAS y el vending no lo tocan.

⑤ ⚠️ Y el encuadre que había que corregir: «el IRC ≡ Lakekeeper» era MEDIO cierto

Lakekeeper  =  catálogo Iceberg REST  +  API de gestión de warehouses   ← UN binario
Gravitino   =  IRC (catálogo REST)    +  servidor (registro de catálogos) ← DOS piezas

El IRC equivale a la mitad de catálogo. La mitad de gestión —el alta por API, que provisionTenantWarehouse ya usa contra Lakekeeper— no existe en el IRC suelto. Por eso §4 anotó «se pierde el alta de warehouse por API»: no era una limitación de Gravitino, era la consecuencia de haber desplegado media pieza.

Lo que cuesta, sin maquillar: una pieza más que operar · ⚠️ un modo de fallo nuevo —servidor caído + caché fría ⇒ 500—, que con caché caliente (1 h) falla tarde y de forma intermitente, lo cual es peor que fallar pronto · ⚠️ y trae capacidades que NO usamos (tópicos, filesets, modelos, RBAC, linaje): sigue valiendo el aviso de no contarlas como propias. Usamos una: el registro de catálogos.

Decisión abierta del owner (ninguna es mala): ① seguir con las dos piezas —el estándar, ya probado, y el modo de fallo se mitiga con réplicas— · ② volver a estático y quitar el servidor (el IRC vuelve a ser autónomo, pero organización nueva = editar un Secret y reiniciar) · ③ reconsiderar Lakekeeper, que da las dos mitades en un binario —contra él pesan /plan y la latencia 3×; a favor, una pieza menos.

⑥ ⚠️ «Ahora tenemos federación, Hive y todo eso» — NO. Medido

Pregunta del owner tras D·0. La imagen trae 12 conectores (fileset · hive · jdbc-doris · jdbc-mysql · jdbc-postgresql · jdbc-starrocks · kafka · lakehouse-generic · lakehouse-hudi · lakehouse-iceberg · lakehouse-paimon · model), y el metalake es
el completo: aceptó un catálogo MODEL (11 en el metalake). Y aun así no la tenemos.

crear catálogo MODEL en el servidor    → 200 · el metalake lo registra
pedirlo por el IRC                     → 400 · «carbon.probe_model is not iceberg catalog»

La frontera: nuestros motores entran por Iceberg REST, y el IRC sirve sólo lakehouse-iceberg (DynamicIcebergConfigProvider lo comprueba con un Preconditions). Un catálogo Hive, Kafka o MySQL sería registrable y NO alcanzable — haría falta un consumidor que hable la API de Gravitino, que en la plataforma no existe.

Y tres cosas más que no cambian por tener la imagen:

  • Ningún backend está cableado: sin metastore Hive, sin Kafka, sin credenciales. Un conector sin backend es un jar.
  • El RBAC unificado está APAGADO (authorization.enable sin cablear). Y por §4·2, Gravitino administra, no aplica.
  • ⚠️ Hay decisión previa EN CONTRA: federación fuera del norte ([carbon-sql-parser-decision]). Esto es capacidad, no dirección: adoptarla sería revertir una decisión, no aprovechar una que ya estaba.

⇒ Sigue valiendo literal el aviso de §2·1: no contar como propia una capacidad que no está encendida. Encendida hay una: el registro de catálogos.

⑦ El peso en el cluster, medido

                  USA REAL       RESERVA (request)   LÍMITE
gravitino-server  3m · 332Mi     250m · 1Gi          1 CPU · 2Gi
gravitino-irc     2m · 356Mi     250m · 1Gi          1 CPU · 1536Mi
nodo              181m/3920m CPU (4%) · 3339Mi/13,3Gi (25%)

⇒ Consumo real ruido (3 milicores, 332 MiB). Lo que cuesta es la reserva: 250m (6,4% del nodo) y 1 GiB (7,5%), que el planificador aparta aunque no se use. €0 adicionales: cabe en el nodo que ya se paga.

⚠️ Reservar 1 GiB para algo que usa 332 MiB estorba a L·2 (OpenMetadata), que ya necesitaba un segundo nodo. Bajar el request a 512Mi devuelve medio giga sin tocar el límite. [pendiente de decisión]

⑧ 🪤 Las cuatro trampas de esta tanda

⛔⛔loadTable devolvió 200 sirviendo la llave LARGA. El síntoma es mudo: un cliente funciona igual. Sólo el log lo decía — Generate credential: s3-secret-key. Que la respuesta sea 200 no dice qué credencial lleva dentro
⛔⛔La fuente que copiamos nunca tuvo el valor bueno. D·1 copió el Secret irc-catalogs, que dice s3-secret-key: el volteo a r2-token lo hacía un sed del initContainer sobre el conf, así que el valor bueno sólo existía en memoria del pod. Y el script usaba setdefault, que no pisa
El IRC cachea el wrapper del catálogo por NOMBRE (expireAfterAccess, 1 h). ⇒ un catálogo nuevo aparece al instante, pero uno modificado tarda hasta una hora. Y reusar el nombre de una sonda anterior sirve la config vieja
⚠️Clonar no es reenviar lo que devuelve el GET: la respuesta trae propiedades DERIVADAS (in-use) que el POST rechaza con 400 y sin mensaje. Y Gravitino no borra un catálogo «en uso»: hay que deshabilitarlo antes (si no, 409 y se queda vivo)


7 · El balance, sin maquillar

⭐⭐ Se gana/plan (el sitio donde poner la política, que Lakekeeper no tiene) · 40× de latencia (5,2 ms vs 222 ms, y una query de N tablas hace ≥N cargas) · el catálogo dentro del cluster · y la puerta abierta al metalake para activos heterogéneos
Se pierdeEl commit multi-tabla declarado y sin estrenar (§3) · el alta de warehouse por API (§4)
⚠️ Hay que traducirWarehouse (Lakekeeper) → catálogo (Gravitino) · el perfil de almacenamiento · y el soft-delete
No se tocaLa gobernanza entera: la cara, Index, la puerta, la retícula y las políticas

El cutover es de la pieza más profunda y la menos gobernante. Por eso se puede
hacer sin mover un solo grant — y por eso hay que hacerlo con la base decidida,
porque lo que se sustituye es donde vive la atomicidad.


8 · ⭐⭐⭐ Y qué gana esta base el día que INDEX se mude a ella

carbon-catalog no se creó sólo para el catálogo: es el paso 1 de dos (§8·quater·bis, opciones A → E). Lo que aporta la segunda mitad:

① La razón fuerte: cerrar 4·4 sin inventar un protocolo de dos fases

HOY      catálogo (su PG) ──✂── Index (Supabase us-east-1)
         ⇒ ventana entre el commit y su asiento ⇒ reconciliación MANUAL
         (`ControlPlaneUnrecordedError`: «el snapshot SE COMMITEÓ y el control-plane
          no pudo registrarlo… NO reintentar a ciegas»)

DESTINO  catálogo (esquema A) ── Index (esquema B) en la MISMA base
         ⇒ el commit y su asiento caben en UNA transacción

Es la única de las siete debilidades de escritura que esta absorción puede cerrar, y no se cierra con código: se cierra con topología. ⚠️ Con su condición: el asiento se hace alrededor de las tablas del catálogo, no dentro — misma conexión y misma transacción, tablas distintas; escribir en su esquema sería acoplarse a su interior.

② La razón que se nota todos los días: la gobernanza deja de cruzar el Atlántico

Index está en us-east-1: 119 ms desde el cómputo. Y la cara pregunta «¿de quién es esta tabla?», «¿tiene política?», «¿puede este principal?» en cada petición.

⇒ Mudarlo a europe-west1 lo baja a ~1-3 ms. No es una optimización: hoy la comprobación de gobernanza cuesta más que el catálogo entero.

③ La que abre puertas: el JOIN vuelve a ser un JOIN

Con las dos mitades en la misma base, «qué tabla existe» (catálogo) y «quién puede tocarla, bajo qué política y quién la tocó» (Index) se cruzan en SQL. Hoy eso exige dos viajes y correlacionar en memoria — y es exactamente lo que necesita el espacio de acción de un agente (§5·sexies): catálogo ∩ retícula, calculado, no correlacionado.

⚠️ Y el precio, que hay que medir antes

No son los 6,5 MB: son las consultas que hoy cruzan las 15 tablas de Index Catalog con las tablas de producto (project_files, tiers, la UI). Hay una deuda conocida — «desacoplar dataset de project_files»— y ése es el coste real de la mudanza. [medir cuántas y cuáles]