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, eI·0que midió su puerta.
Se apoya en el hallazgo G2 derepaso-paradigma-e2e.md
§4·5·ter.
Dónde está esto ahora mismo
| 🏁 T·0 | Spark lee Postgres por JDBC — medido, con diferencial de tipos (§4·bis) |
| ⛔→🏁 T·1 | La 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·0 | La 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.physicalFromLogicalbuscaba por dónde viven los bytes
mientrasmaterializeTableestampaba cómo se llama la tabla ⇒ 8 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
| Antes | Ahora | |
|---|---|---|
| La Fase A | «Spark escribe Iceberg por la puerta» — canarizado como motor externo | Es 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 fase | Deja 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/b | reparaciones para desbloquear T·1 | Siguen 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
| Pieza | Qué la sustituye |
|---|---|
postgresql-adapter.ts — introspección + lotes | el dialecto JDBC de Spark |
normalizeDbType → BASE_TYPE_TO_ICEBERG | el mapeo de tipos del motor |
ingest-client.ts — transporte NDJSON | ninguno: no hay dato cruzando Node |
fanout-client.ts + orquestador de particiones | el paralelismo de Spark |
writer.py — las 4 ramas de escritura | CREATE TABLE … AS SELECT / MERGE INTO |
iceberg-native-write.ts — plano de control propio | el 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
decidePrivilege — cuatro 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·1 | La sentencia entera, por la puerta | INTENTADO 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 columna | 0 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·3 | ⛔ La credencial: cómo llega el secreto al motor | Un 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·4 | El delta: MERGE INTO … USING (SELECT … WHERE cursor > x) en vez de table.upsert | ratify dice committed sobre una incremental (§7) |
| 🏁/⏳ T·5 | Retirar, pieza a pieza, con trinquete por cada una | grep 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 origen | HOY | SPARK | |
|---|---|---|---|
numeric(20,10) | decimal(38,9) | decimal(20,10) | ⭐ MEJORA |
text[] | string (JSON) | array<string> | ⭐ MEJORA |
time | time | timestamp | ⛔ REGRESIÓN |
smallint | int | smallint | ⚠️ 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
- ⛔
time → timestampes la única regresión real. El dialecto JDBC de Spark no tiene tipo TIME y lo eleva atimestamp, inventando fecha. Iceberg sí tienetime, y el carril de hoy lo mapea bien. Hay que resolverlo (cast explícito en elSELECT, o dialecto propio) antes de retirar nada. - ⚠️
smallintNO 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 ainty el punto desaparece. La comparación buena es Iceberg-contra-Iceberg, y ésa exige la escritura (T·1). - ⛔ Y el diferencial TAPÓ un empate que no lo es. Normalicé
timestamptzytimestampa la misma familia para no contar ruido, y con eso escondí que Spark colapsa las DOS variantes de Postgres en un solotimestamp. 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 TABLEdel SQL Editor, que esCREATE+
INSERTpor 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 deWHERE.⚠️ 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·0 | Resolver por la coordenada, no por la ubicación | HECHO 08-13 — 8/8 invisibles → 0/8, control negativo 5/5 |
| 🏁 T·1·a | Compensar también los fallos posteriores — el registro en Index tiene que deshacerse si la tabla no llega a existir | HECHO 08-13 — y el criterio es preguntar al catálogo, no inferir del !res.ok |
| 🏁 T·1·b | dropTable reconcilia Index | HECHO 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óstico | Re-correr el canary: nunca llegó a ejercerse, murió antes en T·1·0 |
| ⏳ T·1·d | Medir el camino de dos pasos, que es el que la cara ya sirve | CREATE + 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)
| Pieza | Dónde | Criterio que la define |
|---|---|---|
physicalFromLogical por coordenada | lib/governance/catalog-names.ts | El 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 |
compensateIfPhantom | lib/governance/table-materialise.ts | La duda no borra: se compensa un «no existe» que dijo el catálogo, nunca un !res.ok. Acotado por service |
reconcileDropInIndex | ídem | NO acotado por service: el usuario soltó la tabla, dé igual quién la creó. Se lleva el puntero de Files |
| El cableado de las tres | app/api/iceberg/v1/[...path]/route.ts | El 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:
| Pieza | Qué 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 / getBackend | La 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.ts —npx 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
runQuerynombrar una fuente externa en elSELECT?
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 DB | leer sin copiar | espejo read-only del catálogo ajeno |
| Lakeflow Connect · Openflow | CDC incremental | conectores 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íamos | Qué dice el estándar | |
|---|---|---|
| ⛔ | La fuente es una fila cifrada en user_credentials, fuera de la retícula | Es 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 propio | Pipelines 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 unconnection
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í:
- El contrato ODCS deja de faltar.
INGESTION.mdya dice que «no falta información para el contrato, falta el objeto donde ponerla» — el objeto es éste. - 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.
- 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/cdcsigue 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 aceptasnapshot_properties, y de ahí queisAttributable()declareupsertno-verificable. UnMERGE INTOpor 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 | |
|---|---|---|
| 🏁 | MEDIDO: entra por --packages (§4·bis) | |
| 🏁 | RETIRADO (§4·quater): el inventario de la Fase A empieza con una entrada menos | |
| ⛔ | deleteAll() no suelta la tabla Iceberg — destapado al retirar el brazo PG | Hoy un borrado de datasets deja tabla y bytes huérfanos. Es el gemelo de T·1·b |
| ⛔ | time → timestamp — la única regresión medida | Bloquea 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 conserva | Lo 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 real | Es lo que sustituye al fan-out. Sin ella, la fase no escala y el fan-out no se puede retirar |
| ⏳ | Egress GKE → Postgres del cliente | Puede 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.