Published

La ingesta como QUERY — retirar el tubo hecho a mano

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

La ingesta como QUERY — retirar el tubo hecho a mano

Approach abierto: 2026-08-13 · v1.1 — la retirada EMPEZÓ. Propone, no
describe. Regla de la casa: cada hecho lleva el comando que lo demuestra; lo que
no lo lleva va marcado como pendiente (⏳) u opinión.

Sucede en ambición —no en contenido— a INGESTION.md,
que describe el tubo actual, e I·0 que midió su puerta.
Se apoya en el hallazgo G2 de repaso-paradigma-e2e.md
§4·5·ter.

Dónde está esto ahora mismo

🏁 T·0Spark lee Postgres por JDBC — medido, con diferencial de tipos (§4·bis)
⛔→🏁 T·1La sentencia por la puerta — ROJO, y sus tres defectos de la cara están reparados (T·1·0/a/b, §4·ter). Falta re-correr el canary
🏁 T·5·0La retirada empezó por lo que ya no ejecutaba: el brazo PG, −808 líneas (§4·quater)
Lo demás — el orden está en §4·quinquies, y no se puede saltar
⭐⭐⭐El estándar, consultado (§4·sexies): la fuente es un securable, el secreto un objeto, y federar ≠ ingerir — el CDC no se hace con un CTAS ni allí

⭐⭐⭐ El hallazgo que más cambia las cosas (08-13): el 404 que tumbó T·1 no era
del camino atómico
. physicalFromLogical buscaba por dónde viven los bytes
mientras materializeTable estampaba cómo se llama la tabla8 de 8 tablas
divergentes eran invisibles, 7 creadas por la propia cara
. Reparado. Ver §4·ter.

Lo que NO se puede hacer todavía, y conviene decirlo antes que nada: retirar
el camino VIVO (Node + PyIceberg). Es por donde entra el 100 % del dato nuevo y su
sustituto no está probado. Tumbarlo antes no sería adelantar la migración: sería
dejar el producto sin ingesta. El inventario de lo que se borra está escrito (§4) y
su gate es T·5.

⭐⭐⭐ VIRAJE DEL 2026-08-13 (tarde) — T·1 MEDÍA EL CARRIL EQUIVOCADO

Hay DOS caminos de un motor al catálogo, y este approach eligió el que el producto no usa.

① POR LA PUERTA        runQuery → Junction → Spark → la cara → Lakekeeper
   La puerta le da al motor  namespace FÍSICO + nombre LÓGICO   (junction.ts:1918)
   🏁 MEDIDO EN PRODUCCIÓN: W0-W5 · P·3 (el plan manda el destino) ·
      P·5 (ALTER con reconciliación de Index) · N·5 (la coordenada direcciona)

② MOTOR EXTERNO        Spark configurado a mano contra /api/iceberg
   Habla en coordenada LÓGICA de punta a punta
   ⛔ Es lo que midió el canary T·1 — y ahí es donde estaba el 404

El canary levantó un Job de Spark en GKE apuntando spark.sql.catalog.carbon.uri a la cara. Eso es el carril ②. El SQL Editor —que ya hace CREATE, INSERT, UPDATE, MERGE, DELETE y ALTER en producción— usa el ①, y por eso nunca tropezó con la resolución: la puerta le manda al motor el namespace físico ya resuelto.

La prueba de que los dos carriles son distintos está en los datos: p4_fx_a es una de las 8 tablas que mi sonda midió como invisibles por coordenada lógica… y es exactamente la fixture contra la que P·3 y P·5 pasan 4/4 en producción.

Lo que esto le hace al approach

AntesAhora
La Fase A«Spark escribe Iceberg por la puerta» — canarizado como motor externoEs runQuery("CREATE TABLE dst AS SELECT … FROM fuente"), igual que el SQL Editor. La puerta ya resuelve coordenada, materializa, firma, deja ledger y reconcilia
T·1·c (¿sirve la cara el CTAS atómico?)El bloqueante de la faseDeja de bloquear: por la puerta, la forma de la sentencia la decide runQuery, no un Spark configurado a mano
Lo que falta de verdad«servir un protocolo nuevo»Que la fuente externa sea NOMBRABLE en el SELECT — que es la Fase B (§5), no la A
T·1·0/a/breparaciones para desbloquear T·1Siguen valiendo, y son del carril ②: la cara tiene que servir bien a un motor externo. Pero no bloquean la ingesta

⭐⭐ La lección: el approach dijo «el sustrato ya está puesto; lo que falta es
dejar de rodearlo»
(§9) y luego lo rodeó: montó un carril nuevo para probar algo
que la puerta ya hacía. Antes de canarizar un camino, mirar por cuál entra el
producto
— el trabajo ya estaba hecho en el paradigma del SQL Editor.


1 · El norte, en una frase

La ingesta deja de ser un programa y pasa a ser una sentencia.

CREATE TABLE <destino> AS SELECT * FROM <fuente>

Hoy la ingesta es un carril propio: un worker Node que sondea, un traductor de tipos escrito a mano, un transporte NDJSON, un fan-out, un escritor PyIceberg y un plano de control aparte. Nada de eso existe porque haga falta: existe porque cuando se escribió no había motor. Ahora lo hay.


2 · ⛔ Esto NO es «cambiar de catálogo», y por eso §4·6 no se hereda

El repaso ordenó «antes de cambiar de catálogo, la ingesta tiene que pasar por la cara» con tres razones. Se escribieron contra una SUSTITUCIÓN (Polaris). Frente a lo que propone este documento hay que releerlas, no heredarlas:

Razón de §4·6¿Sobrevive?
«Hoy entra por fuera el 100 % del dato; cambiar lo de debajo no protege una puerta que no se cruza»Se INVIERTE. Si la ingesta es una query, entra por Junction — que es la puerta. Retirar el tubo no es una alternativa a gobernarlo: es la forma fuerte de gobernarlo
«La cara es la capa que absorbe el cambio»Refuerza: sigue delante, y ahora también de la ingesta
«Si cambias el catálogo primero, meter la ingesta por la cara hay que hacerlo igual después»Sigue en pie, y es el motivo de mantener I·1·b como red mientras el tubo viva (§7)

3 · ⭐⭐⭐ El corte que ordena todo: son DOS mitades, y sólo una necesita Gravitino

Es la distinción que decide el coste, y conviene hacerla antes que nada:

① LA EJECUCIÓN — «el tubo muere»
   Spark lee la fuente por JDBC y escribe Iceberg por la puerta.
   ⇒ NO necesita ningún catálogo nuevo. El sustrato ya está en pie.

② EL REGISTRO — «la fuente deja de ser un tubo y pasa a ser un OBJETO»
   La conexión se da de alta como catálogo; sus tablas existen en el plano de
   metadatos sin copiarse, y el CONTRATO deriva de ahí.
   ⇒ esto SÍ es Gravitino (G2: «una conexión, en su vocabulario, es un catálogo»).

La mitad que retira el tubo no depende de la decisión de catálogo. Se puede
hacer hoy, es reversible, y deja la decisión Polaris/Gravitino exactamente igual de
abierta que ahora. Meterlas en el mismo paquete sería atar una obra barata y medible
a una decisión de plataforma que todavía no está tomada.


4 · FASE A — el tubo muere · sin catálogo nuevo

Lo que se retira, y no es poco

PiezaQué la sustituye
postgresql-adapter.ts — introspección + lotesel dialecto JDBC de Spark
normalizeDbTypeBASE_TYPE_TO_ICEBERGel mapeo de tipos del motor
ingest-client.ts — transporte NDJSONninguno: no hay dato cruzando Node
fanout-client.ts + orquestador de particionesel paralelismo de Spark
writer.py — las 4 ramas de escrituraCREATE TABLE … AS SELECT / MERGE INTO
iceberg-native-write.ts — plano de control propioel ledger de Junction, que ya existe

Y lo que se gana sin escribirlo: la escritura pasa por plan-reader, la firma el commit la cara, el asiento cae en access_events, y la autorización la decide decidePrivilegecuatro piezas que hoy la ingesta no toca y que no habría que construir.

Los gates, en orden de lo que puede tumbar la fase

QuéGate
🏁 T·0¿Puede Spark leer una fuente real?HECHO 2026-08-13 — §4·bis
T·1La sentencia entera, por la puertaINTENTADO 2026-08-13 — ROJO, y encontró tres defectos de la cara (§4·ter)
T·2⭐⭐ Los tipos, EN DIFERENCIAL — la misma tabla por los dos carriles, esquema Iceberg columna a columna0 columnas que el carril nuevo degrade · y ≥1 que mejore. Candidata cantada: hoy todo Decimal cae a decimal(38,9) fijo ⇒ numeric(20,10) pierde escala en silencio. Spark preserva la escala del origen
T·3La credencial: cómo llega el secreto al motorUn canary donde el motor lee la fuente y el secreto no aparece ni en el plan, ni en los logs, ni en el ledger. Es el gate que puede obligar a rediseñar la fase
T·4El delta: MERGE INTO … USING (SELECT … WHERE cursor > x) en vez de table.upsertratify dice committed sobre una incremental (§7)
🏁/⏳ T·5Retirar, pieza a pieza, con trinquete por cada unagrep a 0 + un check: que lo mantenga. T·5·0 hecho (§4·quater); el resto espera a T·1–T·4

⚠️ T·2 es el gate que justifica la fase ante alguien que no la quiera hacer, y por eso va antes que retirar nada: convierte «es más elegante» en «pierde menos dato».


4·bis · 🏁 T·0 — MEDIDO (2026-08-13)

infra/gke/t0-ingesta-jdbc.yaml — Postgres desechable levantado en el namespace, Spark en --master local[1], todo destruido al acabar. No tocó producción: ni carbon-connect, ni el warehouse, ni un dato de cliente.

(Se bajó a un Job de un pod porque el nodo está al 88 % de CPU y 87 % de memoria en requests — el perfil de s2 (driver + 2 executors) no cabía.)

GATE 1  driver org.postgresql.Driver CARGADO           ← por `--packages` (Ivy)
GATE 2  count(*) = 1                                   ← Spark LEE Postgres por JDBC
GATE 5  negativo OK (tabla inexistente → excepción)

La Fase A se sostiene. El driver entra por el mismo mecanismo que Iceberg —una coordenada Maven en --packages—, y el CR carbon-connect ya lo usa para dos.

⭐⭐ El diferencial de tipos — lo que decide si la fase merece la pena

Columna origenHOYSPARK
numeric(20,10)decimal(38,9)decimal(20,10)MEJORA
text[]string (JSON)array<string>MEJORA
timetimetimestampREGRESIÓN
smallintintsmallint⚠️ falsa alarma (§ abajo)
las otras 11=

Y la mejora está probada EN EL VALOR, no sólo en el tipo: la fila llevaba 1.0123456789 —diez decimales— y llegó entera. Por el carril de hoy el décimo se cae en silencio, porque Decimal mapea a decimal(38,9) fijo.

Eso es lo que convierte «es más elegante» en «pierde menos dato», que era el
propósito del gate.

⚠️ Tres reservas del instrumento, y una es un fallo mío

  1. time → timestamp es la única regresión real. El dialecto JDBC de Spark no tiene tipo TIME y lo eleva a timestamp, inventando fecha. Iceberg tiene time, y el carril de hoy lo mapea bien. Hay que resolverlo (cast explícito en el SELECT, o dialecto propio) antes de retirar nada.
  2. ⚠️ smallint NO es una regresión: es mi comparación la que está mal planteada. Comparé tipos de Spark contra tipos de Iceberg. Iceberg no tiene entero de 16 bits, así que al escribir volvería a int y el punto desaparece. La comparación buena es Iceberg-contra-Iceberg, y ésa exige la escritura (T·1).
  3. Y el diferencial TAPÓ un empate que no lo es. Normalicé timestamptz y timestamp a la misma familia para no contar ruido, y con eso escondí que Spark colapsa las DOS variantes de Postgres en un solo timestamp. Hoy el carril inventa zona para el local; Spark pierde la zona del que sí la tiene. Ninguno de los dos es fiel, y el carril nuevo no lo arregla. Una normalización que evita ruido puede esconder señal — hay que declararla.

⚠️ Y un fallo del gate 1, que es la lección más cara

La v1 comprobaba el driver con _jvm.java.lang.Class.forName(...) y dio FALLO con el driver perfectamente resuelto — el log de Ivy lo mostraba descargado y añadido al contexto. Class.forName usa el classloader del llamante, que vía py4j es el del sistema, y los jars de --packages viven en el de usuario.

⇒ Dos correcciones, y la segunda importa más: se pregunta con el context classloader, y el gate pasó a ser informativo. Hacía sys.exit(1), así que un fallo del instrumento abortó la medición entera — una sonda escrita para separar dos causas introdujo una tercera y se paró en ella (regla 52).


4·ter · ⛔ T·1 — ROJO, y lo que encuentra vale más que un verde (2026-08-13)

infra/gke/t1-ingesta-ctas.yaml · identidad lakekeeper-spark-etl (rol writer: el único de los que viven en el cluster que puede crear y soltar lo que crea).

Lo que SÍ quedó probado:

GATE 1  namespaces = ['main','prod','karma']   ← el prefijo de inquilino viaja
GATE 2  bounds reales lo=1 hi=5000 · particiones de lectura = 4
        (fetchsize=10000, partitionColumn=id, bounds LEÍDOS de la fuente)

Y donde murió:

AtomicCreateTableAsSelectExec → writeToTable → commitStagedChanges
  RESTException 404: la tabla no existe en este catálogo (main.default.t1_canary_ctas)

⛔ ① Spark hace CTAS ATÓMICO, y la cara no lo sirve

CREATE TABLE … AS SELECT no es una operación REST: Spark escenifica la tabla (stage-create), escribe los ficheros, y commitea lo escenificado en una llamada aparte. Son tres operaciones, y la tercera muere con el unknown-table de la cara sobre el nombre lógico.

La cara nació midiendo el CREATE TABLE del SQL Editor, que es CREATE +
INSERT por separado.
El camino atómico es otro, y nunca se había ejercido.
Es la regla 51 otra vez: el canary ve lo que los tests no.

⛔⛔⛔ CORRECCIÓN (2026-08-13) — ① era una ATRIBUCIÓN, y estaba equivocada

«La cara no sirve el camino atómico» explicaba el 404 sin haberlo comprobado. La causa real la da la sonda scripts/warehouse/t10-resolucion-coordenada.ts, y no tiene nada que ver con la atomicidad:

materializeTable    estampa   iceberg_namespace = el namespace FÍSICO (opaco)
physicalFromLogical buscaba   .eq('iceberg_namespace', el que pide el MOTOR)

Son dos claves distintas: una dice dónde viven los bytes, la otra cómo se llama la tabla. Fueron la misma cosa mientras existió el espejo {catalog}.{schema} — y el paradigma de nombres lo retiró a propósito.

123 datasets · 95 con lógica == física (el espejo) · ⛔ 8 DIVERGEN
⇒ 8/8 NO RESUELVEN por su nombre lógico
⇒ 7 de los 8 son `sql_editor.create_table`: tablas que creó LA PROPIA CARA

Una tabla nacida por la cara con el régimen opaco encendido era invisible desde el instante en que nacía. No sólo para el CTAS: para cualquier loadTable, updateTable o INSERT posterior sobre su nombre. El canary no fue el primero en sufrirlo — fue el primero en notarlo, porque las siete anteriores nadie las volvió a pedir por su nombre.

🏁 T·1·0 · reparado: la resolución busca por (workspace, schema_id, name) — la misma clave con la que materializeTable comprueba la colisión. El camino por ubicación se conserva como respaldo para el motor que pide la coordenada física.

0/8 invisibles   ·   control negativo 5/5   ·   26 tests en catalog-names

⭐⭐ Y la lección, que es de método: el canary dio un síntoma (404 en la tercera
llamada) y se escribió la causa más vistosa —«el camino atómico»— porque encajaba con
lo que se acababa de aprender. Una hipótesis que explica el síntoma no es la causa
hasta que se mide.
Aquí la diferencia era enorme: una es servir un protocolo nuevo;
la otra, dos líneas de WHERE.

⚠️ Lo que sigue sin saberse: si la cara sirve o no el CTAS atómico. Nunca llegó
a ejercerse — murió antes, en la resolución. T·1·c deja de estar diagnosticado y
vuelve a ser una pregunta abierta
(§4·quinquies).

⛔⛔ ② Un fallo DESPUÉS de materializar deja huérfana la fila de Index

La compensación de la cara (compensateMaterialise) sólo corre if (materializado && !res.ok) — es decir, si el createTable de aguas arriba rechaza. Aquí ese paso fue bien y el fallo llegó en una llamada REST posterior, así que no compensó nada.

Medido, y limpiado a mano:

datasets → t1_canary_ctas · row_count 0 · created_by lakekeeper-spark-etl
           físico: prod.w_7b50….ds_f9d32d1c…
¿hay tabla en el catálogo? → 404   ⇒ fila huérfana PURA (sin bytes detrás)

⛔⛔ ③ Y el motor no puede repararlo solo: DROP no reconcilia Index

La segunda pasada murió con TABLE_OR_VIEW_ALREADY_EXISTS después de un DROP TABLE IF EXISTS que no lanzó. ⇒ el DROP se llevó (o no encontró) la tabla del catálogo y dejó la fila de Index en pie, así que el CREATE siguiente la sigue viendo.

Ésta es la peor de las tres: una deriva que el motor crea y no puede
deshacer
, y que además bloquea el reintento. La cara materializa al crear y no
reconcilia al soltar — las dos mitades de W4 no están.

⚠️ ④ Menor: …/tables/{t}/metrics no está gobernada

RESTMetricsReporter la llama tras cada scan y recibe el 404 honesto de la cara («operación no gobernada»). No es fatal —queda en WARN— pero ensucia el log de cualquier motor real. Está en la lista de rutas que la cara deja fuera a propósito.

Lo que esto NO dice

⚠️ No dice que la ingesta como query no funcione. Dice que la cara no sirve el camino atómico, que es distinto y es reparable. El de dos pasos no llegó a medirse —lo bloqueó el huérfano de ②— así que sigue ⏳.

El orden que sale de aquí

QuéGate
🏁 T·1·0Resolver por la coordenada, no por la ubicaciónHECHO 08-13 — 8/8 invisibles → 0/8, control negativo 5/5
🏁 T·1·aCompensar también los fallos posteriores — el registro en Index tiene que deshacerse si la tabla no llega a existirHECHO 08-13 — y el criterio es preguntar al catálogo, no inferir del !res.ok
🏁 T·1·bdropTable reconcilia IndexHECHO 08-13 — la fila y su puntero de Files se retiran con la tabla
T·1·c¿Sirve la cara el camino atómico? — vuelve a ser PREGUNTA, no diagnósticoRe-correr el canary: nunca llegó a ejercerse, murió antes en T·1·0
T·1·dMedir el camino de dos pasos, que es el que la cara ya sirveCREATE + INSERT … SELECT verde, con firma y asiento

Y 0/a/b son de la cara, no de la ingesta: los sufre igual cualquier motor que escriba. Salen de aquí porque este canary fue el primero en ejercerlos.

🏁 Lo que quedó cableado (2026-08-13)

PiezaDóndeCriterio que la define
physicalFromLogical por coordenadalib/governance/catalog-names.tsEl que CREA y el que RESUELVE usan la misma clave. schemaIdFromNamespace mudó aquí desde table-materialise — vivía con el que crea, y de ahí salió la separación
compensateIfPhantomlib/governance/table-materialise.tsLa duda no borra: se compensa un «no existe» que dijo el catálogo, nunca un !res.ok. Acotado por service
reconcileDropInIndexídemNO acotado por service: el usuario soltó la tabla, dé igual quién la creó. Se lleva el puntero de Files
El cableado de las tresapp/api/iceberg/v1/[...path]/route.tsEl loadTable de comprobación reusa la misma URL ya resuelta: reconstruirla es cómo se preguntan dos cosas distintas creyendo que son la misma
36 tests nuevos/actualizados · lib/governance 231/231 verdes

4·quater · 🏁 T·5·0 — la retirada empieza por lo que YA NO EJECUTABA (2026-08-13)

La Fase A retira dos cosas distintas y conviene no mezclarlas, porque tienen gates opuestos:

① EL CAMINO MUERTO — el brazo PG. No ejecuta desde que se borró `dataset_rows`.
   Gate: NINGUNO. Borrarlo no puede romper nada que funcione hoy.        ← HECHO

② EL CAMINO VIVO — Node + PyIceberg. Por aquí entra el 100 % del dato.
   Gate: T·1 verde + T·2 + T·3 + T·4. Sin sustituto, retirarlo es apagar la ingesta.

Lo retirado en ①, medido:

PiezaQué era
PostgresBackend (dataset-writer.ts)INSERT multi-fila · UPSERT ON CONFLICT · COPY FROM STDIN con tabla temporal y backpressure
writeDatasetRowsPg (ídem)El control-plane PG compartido con la puerta — el gemelo legacy de withNativeControlPlane
El brazo postgres de write() (junction.ts)PG_WRITE_MODE · batchRows · stampPgWriteStats — delegaba en el anterior
StorageBackend / S3Backend / getBackendLa abstracción pluggable y su registro. El S3Backend nunca se escribió: R2 llegó por otro sitio
Las «REQUIRED MIGRATION» del pieÍndices ON CONFLICT para una tabla que ya no existe
−808 líneas · 6 ficheros
check:dataset-rows-seam   dataset-writer.ts  9 → 0   (era el mayor conteo del mapa)
tests                     48/48 verdes (junction-pg-write · runquery-write · w3-dialect)

Y el hueco queda tapado, no abierto: donde estaba el brazo hay un throw que nombra la causa y el fichero donde se arregla. Un maxMode: 'pg' mal puesto estalla en la puerta en vez de escribir contra una tabla inexistente. Es la misma forma que ingest.stream (410) y row.edit (409): cerrar en voz alta.

⚠️ Lo que la retirada destapó, y no es de la fase

DatasetWriter.deleteAll() borraba las filas del plano PG y el puntero de Files. Sin plano PG ya no suelta nada del dato: deja la tabla Iceberg y sus bytes huérfanos en el catálogo. Es el mismo hueco que T·1·b encontró por el otro lado —soltar no reconcilia Index— visto desde el ciclo de vida en vez de desde el motor. Queda anotado en el código y en §8; arreglarlo es de la cara, no de la ingesta.

⚠️ Y una mentira persistida que sigue ahí: datasets.storage_backend se escribe 'postgres' en cada nacimiento mientras los bytes van a R2. Nunca aprendió a decir iceberg (ver pasarela.md); corregirla es un cambio de plano de datos, no de este escritor.


4·quinquies · El orden, y por qué no se puede saltar

🏁 T·0   Spark lee la fuente                          ── hecho
🏁 T·5·0 retirar lo inalcanzable                      ── hecho, sin gate
🏁 T·1·0 resolver por coordenada, no por ubicación         ┐ carril ② (motor externo).
🏁 T·1·a compensar los fallos POSTERIORES al materialise   ├ Son de LA CARA y valen,
🏁 T·1·b `dropTable` reconcilia Index                      ┘ pero YA NO BLOQUEAN
🔄 T·1·e ⭐ EL CANARY, REESCRITO POR LA PUERTA         ── `runQuery`, como P·3/P·5
⏳ T·2   diferencial de tipos Iceberg-contra-Iceberg   ── justifica la fase
⛔ T·3   el secreto en el motor                        ── puede rediseñarla
⏳ T·4   el delta por `MERGE INTO`                     ── sin esto no hay incrementales
⏳ T·5   retirar el camino VIVO, pieza a pieza         ── el último, y sólo entonces

T·1·a y T·1·b siguen valiendo aunque el carril cambie, y T·1·b tiene un motivo propio: DROP TABLE no es un verbo admitido por la puerta (DML_VERBS_PERMITIDOS = DELETE · MERGE · UPDATE, más ALTER_SCHEMA), así que hoy una tabla sólo se puede soltar por la cara. La reconciliación de Index tenía que estar ahí.

⏭️ Lo siguiente NO es re-correr el Job de GKE. Es T·1·e: el canary de la ingesta como query, escrito por la puerta, con la misma forma que p3-canary-plan-manda.ts y p5-canary-alter-add-column.tsnpx dotenv -e .env.local -- npx tsx, contra producción, sin cluster, sin preview y sin bypass—. La pregunta que tiene que contestar deja de ser «¿sirve la cara el CTAS atómico?» y pasa a ser:

¿puede runQuery nombrar una fuente externa en el SELECT?

Y ésa ya no es una pregunta de la cara: es la Fase B (§5).

⚠️ Coste conocido de T·1·0, dicho: resolver por coordenada cuesta dos consultas más (catalogs, schemas) por petición con nombre lógico. Es cacheable —la coordenada de un namespace cambia poquísimo— y no se ha cacheado: medir primero si molesta. Un caché puesto por si acaso es un sitio más donde algo puede quedarse rancio.

⚠️ T·2 antes de retirar nada, porque es el que convierte «es más elegante» en «pierde menos dato». Y su comparación buena —Iceberg contra Iceberg— exige la escritura, así que depende de T·1: por eso el diferencial de §4·bis, hecho contra tipos de Spark, es un adelanto y no el gate.


4·sexies · ⭐⭐⭐ QUÉ DICE EL ESTÁNDAR — Databricks y Snowflake, consultados (2026-08-13)

Regla 53: preguntarle al estándar es más barato que decidir. Se preguntó, y reordena la fase entera.

El modelo, y es el MISMO en los dos

① LA FUENTE ES UN OBJETO DEL CATÁLOGO, con grants
   Databricks · CONNECTION  = securable de Unity Catalog
                privilegios USE CONNECTION · CREATE FOREIGN CATALOG
                FOREIGN CATALOG = espejo read-only de la BD externa
   Snowflake  · CATALOG INTEGRATION · EXTERNAL VOLUME · SECRET
                CATALOG-LINKED DATABASE = descubre y sincroniza namespaces solo

② LA CREDENCIAL VIVE EN EL OBJETO, NO EN EL PLAN
   Snowflake lo dice sin rodeos: usar un SECRET en vez de literales es lo que
   permite AUDITAR y gestionar su uso — sólo quien tiene `READ` sobre el secret
   puede emplearlo a través de la integración.

③ LA INGESTA ES UNA SENTENCIA DECLARATIVA SOBRE ESE OBJETO
   Databricks · CREATE OR REFRESH STREAMING TABLE t AS
                  SELECT * FROM STREAM read_files(…)
              · o desde el foreign catalog:  CREATE TABLE t AS SELECT * FROM fc.esq.tabla

⭐⭐ Lo que confirma

La tesis de §1 es la del sector: la ingesta es una sentencia, no un programa. Y el viraje de hoy también: la sentencia entra por el catálogo gobernado, no por un motor configurado a mano contra él.

⭐⭐⭐ Lo que CORRIGE, y es lo caro

① Federar ≠ ingerir, y nosotros lo teníamos fundido. Ninguno de los dos hace CDC con un CTAS:

Para quéCon qué
Foreign catalog / catalog-linked DBleer sin copiarespejo read-only del catálogo ajeno
Lakeflow Connect · OpenflowCDC incrementalconectores gestionados, lineage automático, serverless; Snowflake usa Snowpipe Streaming y slots de replicación de Postgres

El tubo no muere entero: muere su mitad de SNAPSHOT. La mitad de CDC sigue siendo un conector con captura de cambios — que es lo que lib/workers/cdc ya es. §6 lo decía («el CDC no desaparece») y el estándar lo sube de «matiz» a estructura de la fase.

② T·3 (el secreto en el motor) está CONTESTADO, y no era un rediseño. Estaba marcado como «puede obligar a rediseñar la fase». No hace falta un broker de credenciales: el secreto es un securable, y el motor lo consume por referencia con un privilegio (READ en Snowflake, USE CONNECTION en UC). Nosotros ya tenemos la retícula (principal_grants, con herencia por cadena) — falta el objeto.

③ El linaje desde la FUENTE es una consecuencia, no una función aparte. Lakeflow Connect registra el linaje de la tabla de origen a la de destino porque la fuente es un nodo del catálogo. Hoy nuestro linaje empieza en el warehouse: la fuente no aparece porque no es un objeto.

④ Y un dato que baja la urgencia de T·1·c: el propio Snowflake no admite CREATE ICEBERG TABLE … AS SELECT sobre tablas gestionadas por un catálogo externo (Glue, UC). El CTAS contra catálogo ajeno es justo lo que el sector tampoco sirve. Nosotros SOMOS el catálogo, así que no nos aplica igual — pero deja de ser «la pieza que todos tienen y a nosotros nos falta».

Lo que queda OBSOLETO de lo nuestro

Qué teníamosQué dice el estándar
La fuente es una fila cifrada en user_credentials, fuera de la retículaEs un securable con grants. Sin eso, «quién puede ingerir de dónde» no es una pregunta que se pueda contestar
El destino se elige en el ALTA (metadata.warehouse_destination, paso 2 del asistente)La conexión no tiene destino: el destino lo pone la SENTENCIA. Por eso 17 de 26 fuentes están hoy en un main.default que nadie eligió — el modelo pedía esa decisión en el sitio equivocado
🟡El worker de polling como planificador propioPipelines declarativas gestionadas. El planificador se queda (§6), pero su contrato debería ser una tabla declarada, no un worker con sync_config
🟡«El contrato ODCS falta porque falta el objeto» (INGESTION.md)Confirmado, y con nombre: el objeto es la CONNECTION, y de ella cuelga el catálogo federado

Regla 46 aplicada: se toma el MODELO —la fuente como securable, el secreto como
objeto, la ingesta como sentencia— y se deriva en nuestro sustrato. Lo que NO se
copia es la implementación: no necesitamos Lakeflow ni NiFi para tener un connection
gobernado en Index.


5 · FASE B — la fuente pasa a ser un OBJETO

Medido en G2: montar un catálogo ajeno funciona con el bucle cerrado —se creó un schema a través de Gravitino y el catálogo de abajo lo vio—, y se adopta como superposición, sin migrar y reversible:

la cara ──▶ Gravitino (tipos + federación) ──▶ Lakekeeper (sigue siendo el registro)

Lo que compra, y sólo se puede comprar aquí:

  1. El contrato ODCS deja de faltar. INGESTION.md ya dice que «no falta información para el contrato, falta el objeto donde ponerla» — el objeto es éste.
  2. El dataset es gobernable antes de copiarse un byte. Grants y etiquetas dejan de llegar tarde, que es lo que F·3 del plano gobernado persigue.
  3. Un solo modelo de tipos — el concepto ⑮ del mapa, hoy dos planos que no se hablan.

⚠️ Corrección al repaso §4·5·ter

Dice «adoptarlo no nos da gobernanza» apoyándose en el 405 de /roles. Ese 405 es gravitino.authorization.enable SIN PONER, no un techo del producto — y el servidor completo no está en pie. La afirmación honesta es «no la hemos medido encendida». Es exactamente el error que el propio repaso nombra en §4·2: «un argumento construido sobre capacidades que no hemos encendido no compara dos sistemas: compara dos folletos».

Gate B·0, antes de cualquier juicio sobre su gobernanza: levantar el metalake completo con authorization.enable=true y medir /roles, /users, /groups — y si su modelo se deriva de algo nuestro o se declara, que es la pregunta del §2 del plano gobernado.


6 · Lo que NO desaparece — para que esto no se lea como un folleto

  • El calendario y el cursor. CTAS es un snapshot completo. Quién decide cuándo y desde dónde sigue siendo del control-plane. El worker no muere: adelgaza a planificador.
  • El CDC. Federar no da captura de cambios. lib/workers/cdc sigue igual.
  • La política. Ni Gravitino ni Spark la traen: se sigue escribiendo aquí.
  • ⚠️ La red. Hoy el worker de Railway alcanza el Postgres del cliente; mañana tiene que alcanzarlo el executor de GKE. Es un problema de egress, y es real.
  • ⚠️ La CPU. El repaso midió el cluster al 88 %, con CPUS_ALL_REGIONS = 12. La ingesta pasaría a competir con el SQL Editor por el mismo cómputo.

7 · Qué le hace esto a I·1·b y a I·2

  • I·1·b (apuntar el escritor a la cara) sigue valiendo, y baja de rango. Es la red mientras el tubo viva, y su coste ya está pagado salvo el release. Pero si T·0 y T·1 salen verdes, la vida restante del tubo es corta — y eso cambia cuánto merece invertir en gobernarlo.
  • ⭐⭐ I·2 se DISUELVE, no se resuelve. Su problema entero es que table.upsert() de pyiceberg no acepta snapshot_properties, y de ahí que isAttributable() declare upsert no-verificable. Un MERGE INTO por la puerta no tiene ese problema: lo firma la cara como cualquier otro commit. La pieza no hay que construirla — hay que quitar la que la hacía necesaria.

8 · Lo que está SIN MEDIR (⏳) — y decide

QuéPor qué decide
🏁Driver JDBC en la imagenMEDIDO: entra por --packages (§4·bis)
🏁El brazo PG estorbando la retiradaRETIRADO (§4·quater): el inventario de la Fase A empieza con una entrada menos
deleteAll() no suelta la tabla Iceberg — destapado al retirar el brazo PGHoy un borrado de datasets deja tabla y bytes huérfanos. Es el gemelo de T·1·b
time → timestamp — la única regresión medidaBloquea retirar el traductor: hoy ese tipo se mapea bien y el carril nuevo lo degrada
La fidelidad de zona horaria — ninguno de los dos carriles la conservaLo tapó mi propia normalización (§4·bis). Es un problema preexistente, no de la fase, pero deja de poder ignorarse
Lectura particionada (partitionColumn/numPartitions) contra una fuente realEs lo que sustituye al fan-out. Sin ella, la fase no escala y el fan-out no se puede retirar
Egress GKE → Postgres del clientePuede exigir VPC/peering por cliente, y eso es infraestructura, no código
El secreto en el motor (T·3)Puede obligar a un broker de credenciales y cambiar la forma de la fase
Gravitino con autorización encendida (B·0)Corrige el juicio de §4·5·ter

9 · La frase que resume el approach

El sustrato ya está puesto; lo que falta es dejar de rodearlo. Spark, la cara,
Junction, el plan-reader, el ledger y la firma del commit existen y funcionan — y la
ingesta, que es por donde entra el 100 % del dato nuevo, es la única superficie que
los esquiva enteros, con un tubo escrito a mano cuando no había ninguno de ellos.