⭐⭐ INGESTA GOBERNADA · APPROACH — de tubo a escritura
Suelo obligatorio:
ingesta-gobernada-fundamentos.md.
Los pilares ①-⑤ y el mapa de infraestructura se dan por leídos; aquí no se repiten.Qué entrega: que ingerir desde Postgres sea una escritura más — misma puerta,
misma política, mismo motor, mismo catálogo — disparable desde el SQL Editor, desde una
celda de notebook o por un planificador.Qué NO entrega, y se dice ahora: el incremental/CDC. Es terreno reconocido como
pantanoso, y diseñarlo antes de tener una copia funcionando es adivinar. Va en un
approach aparte, y §7 dice qué hay que preservar para no cerrarse la puerta.
1 · El principio que ordena todas las fases
Nada nuevo debajo de la puerta.
Cada capacidad entra porrunQuery, se decide condecidePrivilegey se ejecuta con el
motor que ya hay. Si una fase necesita un camino propio, la fase está mal planteada.
De ahí salen tres consecuencias que valen como criterio de rechazo:
- Un verbo nuevo no trae un ejecutor nuevo. El carril de gobernanza (G·6) ya demostró que interceptar antes del motor es barato.
- Un secreto nuevo no trae un almacén nuevo. El vending de la cara ya existe.
- Un disparador nuevo no trae un motor nuevo. Manual y programado ejecutan lo mismo.
2 · Las fases
🔍 C·0 · El CENSO DEL TUBO — antes de suprimir, saber qué se pierde
El worker actual sabe cosas que no están escritas en ningún sitio: estrategias por tabla
(sequence, timestamp), watermark y reanudación por checkpoint, fan-out por rangos de
PK, mapeo de tipos, descubrimiento de FKs, streaming por lotes con techo de RAM.
Suprimir sin censar es perder capacidades y descubrirlo en producción.
Gate
c0· un documento generado (no escrito a mano) que liste, por capacidad,
si el camino nuevo la cubre, la pospone o la abandona a propósito. Ninguna entrada
puede decir «pendiente de mirar».
🧱 C·1 · La CONEXIÓN, un securable
Entidad en Index con dueño, tenencia y privilegios propios (USE CONNECTION,
CREATE CONNECTION, MANAGE CONNECTION), integrada en la cadena que ya recorre
decidePrivilege. El secreto por referencia al almacén de credenciales que ya existe;
nunca un valor literal en la fila.
⚠️ El vocabulario se queda corto a propósito — la misma regla que MAPEO en
grant-sql.ts: cada entrada nueva es una decisión sobre qué puede hacer alguien.
Gate
c1·decidePrivilegeconcede y deniega sobre una conexión, con control
negativo: un rol sinUSE CONNECTIONno la ve, y un principal de otro inquilino
tampoco. Sin el negativo el gate no distingue nada.
🏁 C·1 · CERRADO (2026-08-16)
connection-securable.test.ts (16 en frío) · c1-gate-conexion-securable.ts (VERDE en
vivo) · 458 tests en lib/governance · tsc limpio · migración 20261290 aplicada.
El modelo: la conexión es un securable LATERAL — cuelga del workspace y no de
la cadena de datos. Meterla en SECURABLE_HIERARCHY habría dicho que está debajo de
dataset, que es falso; su cadena es [workspace, connection] y buscaGrant empareja por
posición, no por profundidad, así que hereda del inquilino sin tocar el orden de
contención. Y no aparece en TRAVESIA_DE: una hoja no se atraviesa.
⭐ Vocabulario de tres, y WORKSPACE_MANAGE_CONTENT implica CONNECTION_USE pero
no CONNECTION_CREATE ni CONNECTION_MANAGE_ACCESS: traer un sistema externo al
inquilino y repartir su credencial no caen de administrar contenido.
⭐⭐ Lo que enseñó el gate: un securable nuevo toca TRES sitios en la base
Y ninguno avisa del otro. El gate los encontró de uno en uno, una corrida por eslabón:
| qué | cómo falla si se olvida | |
|---|---|---|
| ① | CHECK del nombre del privilegio | 23514 — TypeScript, assertGrantable y la API dan 201, Postgres mata |
| ② | El CASE del trigger de tenencia | case not found — y, peor, el securable queda fuera de la comprobación de cruce entre inquilinos |
| ③ | CHECK de securable_kind | rechazo del CHECK, ya con todo lo demás bien |
⇒ El CASE del trigger ahora lleva ELSE explícito: el próximo securable lateral
fallará con un motivo legible en vez de con case not found.
⭐ La asimetría fue lo que señaló al culpable: CONNECTION_CREATE sobre workspace
pasaba y CONNECTION_USE sobre connection no. Un gate que sólo dijera «falla» no
habría distinguido el trigger del CHECK.
📝 C·2 · La GRAMÁTICA
CREATE CONNECTION · DROP CONNECTION · SHOW CONNECTIONS, interceptadas en la puerta
antes del motor, exactamente como el carril de gobernanza.
⚠️ El orden del carril en la ruta es un requisito, no un detalle (lección de G·6·5): la
gramática de catálogo reclama las tres formas de SHOW. Va con test que rompe si alguien
reordena los if.
⭐ Y el vocabulario se deriva, no se copia: el resaltado del editor y el autocompletado salen de la misma constante que el parser — el séptimo sitio de la lista de verbos ya enseñó lo que cuesta olvidarse de uno.
Gate
c2· tests del parser con las formas admitidas y las rechazadas con su
motivo; el test de orden del carril; y el trinquete del léxico
(grant-sql-resaltado.test.tscomo plantilla) verde para los verbos nuevos.
🏁 C·2 · CERRADO (2026-08-16)
connection-sql.test.ts (22) · 479 tests en lib/governance · tsc limpio.
CREATE CONNECTION pg_ventas TYPE POSTGRESQL
OPTIONS (host 'db.example', port '5432', database 'ventas', credential 'ventas-ro')
DROP CONNECTION [IF EXISTS] pg_ventas
SHOW CONNECTIONS
⛔⛔ La regla que define la gramática: el secreto NO cabe en la sentencia.
password, secret, token, pwd, api_key… se rechazan con un motivo que dice qué
hacer — no se ignoran. Un parser que ignora una opción desconocida acepta la sentencia y
deja al usuario creyendo que su contraseña se guardó. credential es una referencia a
user_credentials, que ya cifra. Y el léxico no contiene ningún nombre de secreto:
sugerirlo sería enseñar la forma que el parser rechaza.
⭐⭐ La integración limpia: sql-lexico.ts
El editor derivaba su léxico de una gramática (grant-sql). Con la segunda, la salida
fácil era importar de dos sitios — y de tres con la siguiente: reintroducir el problema del
séptimo sitio con más pasos.
⇒ lib/governance/sql-lexico.ts es el único sitio que el editor conoce. Añadir una
gramática es añadir una línea allí; el resaltado y el autocompletado la heredan sin
tocarse. Cada entrada lleva su reconocedor junto a su léxico, y un test comprueba que
toda gramática registrada tiene quien la conteste — si alguien aportara palabras al
resaltado sin registrar el reconocedor, el editor colorearía verbos que la puerta no
entiende.
Y el ejecutor, que no se dejó para después
Una gramática que se reconoce y no hace nada es SQL que aparenta funcionar — la misma
regla que ya rige para los privilegios. connection-sql-executor.ts autoriza con
decidePrivilege sobre la cadena real (no con criterio propio), lista sólo lo que se
puede usar (un listado que enseña lo que no se puede tocar filtra la topología del
cliente), y hace soft-delete: una conexión retirada sigue explicando de dónde vino lo
que ya se copió. ⛔ Y no lee encrypted_data — hay un test que lo comprueba.
🔐 C·3 · El VENDING de la credencial
Extender el mecanismo de app/api/storage/credential a conexiones: la cara resuelve el
secreto por petición, con TTL corto y scope derivado de conexión × principal.
Invariantes, y son de rechazo:
- el secreto no aparece en el texto del SQL, ni en el plan, ni en el ledger, ni en un log, ni en la config del clúster;
- la credencial caduca sola; si el trabajo dura más, se renueva, no se alarga;
- quien no puede usar la conexión no obtiene credencial, y el motivo lo da el plano de permisos, no el driver.
Gate
c3· un test que busca el secreto en plan, ledger, logs y respuesta, y
falla si aparece. Más control negativo: principal sinUSE CONNECTION→ sin credencial.
🏁 C·3 · CERRADO (2026-08-16)
connection-credential.test.ts (10 en frío) · c3-gate-vending.ts (VERDE en vivo) ·
489 tests · tsc limpio.
⚠️ Lo que NO se pudo hacer, dicho sin adornos
El vending de storage acuña credenciales derivadas y de corta vida porque S3/R2 tienen STS. PostgreSQL no: no hay forma estándar de emitir una contraseña temporal para un Postgres cualquiera. Aquí no se acuña nada — se resuelve el secreto que ya existe.
Fingir lo contrario habría sido el peor resultado: un TTL decorativo hace creer que hay una protección que no está. Lo que sí es real:
- la autorización se comprueba en cada petición, no una vez al configurar;
- el material entregado caduca (
revelar()lanza pasado el TTL), así que un trabajo largo tiene que re-autorizar — y si le revocaronCONNECTION_USEa mitad, falla; - queda registrado quién la pidió, permitido y denegado.
⭐⭐ El invariante no se pide: se hace imposible
SecretoDeConexion no se serializa: JSON.stringify → "[secreto de conexión]",
`${s}` → [secreto de conexión], console.log/util.inspect → igual. El campo es
privado (#valor), así que ni el spread ni Object.keys lo alcanzan. Sólo .revelar()
lo entrega — y eso se encuentra con un grep.
⇒ El gate persigue un valor concreto por todas las vías por las que un secreto se
escapa de verdad: respuesta HTTP, interpolación, logs (capturando console.log),
spread y el ledger de accesos. Un invariante que depende de la disciplina de quien
programa no es un invariante.
⭐ El CUARTO eslabón, y por qué casi no aparece
C·1 encontró tres sitios que hay que tocar al añadir un securable. Hay un cuarto: el
CHECK de access_events… y está particionado por mes. Dos matices que costaron:
- las constraints de las particiones son heredadas: tocarlas una a una da «cannot drop inherited constraint». Se opera sólo sobre la tabla padre y Postgres propaga;
- ⚠️ sólo se ve con
ENABLE_ACCESS_EVENTS=true. Con el interruptor apagado el evento ni se intenta, el gate cuenta 0 y no distingue «apagado» de «roto» — que es exactamente cómo un gate aprueba sin significar nada. El gate ahora lo distingue y dice cuál es el comando para medirlo de verdad.
⇒ Añadir un securable toca CUATRO sitios en la base, y el cuarto vive detrás de un interruptor apagado por defecto.
👁️ C·4 · SHOW SCHEMAS/TABLES IN <conexión>
La introspección la contesta la puerta, no el catálogo — la cara habla Iceberg REST y no tiene el concepto de tabla JDBC (fundamentos §3). Es metadata, no datos: mirar antes de copiar.
Gate
c4· devuelve el censo real de un Postgres de prueba y deniega sin
USE CONNECTION. Que devuelva algo no basta: tiene que negarse cuando toca.
🏁 C·4 · CERRADO (2026-08-16)
c4-gate-introspeccion.ts VERDE contra un Postgres real (Docker): lista los esquemas
y tablas de verdad, deniega sin CONNECTION_USE con motivo del plano de permisos, y no
filtra la contraseña del origen.
⭐⭐ La decisión que ordena esta fase: la desambiguación es de EXISTENCIA
SHOW SCHEMAS IN x y SHOW TABLES IN x.y ya son de la gramática de catálogo (M3),
donde x es un catálogo o schema del warehouse. Interceptarlas por sintaxis le habría
quitado SHOW TABLES IN main.default al warehouse, y el fallo habría aparecido lejos —en
una pantalla que dejó de listar tablas—, no aquí.
⇒ Se extrae el nombre y decide el ejecutor mirando si existe una conexión que se llame
así. Si no la hay devuelve noEsConexion y el flujo sigue hasta el catálogo. Por eso
este carril es el único que no puede return en el caso negativo.
El gate fija las dos formas que el catálogo conserva, y fallan distinto:
SHOW TABLES IN main | un solo segmento: ni siquiera tiene la forma (null) |
SHOW TABLES IN main.default | sí tiene la forma — sólo la existencia lo salva |
El segundo es el que prueba la desambiguación de verdad; el primero solo habría dado una sensación falsa de cobertura.
Dos detalles que costaron una corrida
- SSL: se conecta con
sslmode=prefer(el default depsql) — intenta cifrado y cae a claro sólo si el servidor no lo soporta. Forzar SSL rompía contra cualquier Postgres sin TLS; forzar claro sería peor, mandaría la credencial en abierto contra uno que sí lo admite. - El censo excluye los esquemas del sistema (
pg_*,information_schema): lo que interesa es lo que puso el cliente, no las tripas de Postgres.
📦 C·5 · La COPIA — un solo paso para el usuario, dos para el catálogo
El usuario escribe una sentencia. La puerta la descompone en createTable (sano desde
el 08-16) + INSERT por lotes, porque el CTAS no existe en Gravitino
(apache/gravitino#10750).
⛔⛔ El modo de fallo está documentado en esa misma issue y hay que diseñarlo desde el
principio: si el CREATE va bien y el INSERT falla, la tabla queda registrada
apuntando a metadata que nunca se escribió — y después ni siquiera se puede borrar.
Medido aquí el 08-16: una tabla de prueba quedó exactamente así.
⇒ La descomposición obliga a compensación. La cara ya tiene el precedente
(compensateMaterialise y el bloque G·1 de createTable), y la regla también:
la duda no borra — sólo se compensa cuando el catálogo confirma que no hay nada.
Gate
c5· una copia real de punta a punta con recuento de filas verificado en el
destino, más una copia que falla a propósito en elINSERT: no puede quedar ni
una tabla huérfana ni una fila fantasma. El segundo caso es el que vale.
🏁 C·5 · DESBLOQUEADO (2026-08-16, tarde) — los dos bloqueantes CAYERON
Lo que sigue debajo es el diagnóstico, que se conserva porque explica el porqué. El estado hoy es otro:
| bloqueante | estado | |
|---|---|---|
| ① | Spark no tiene el driver de PostgreSQL | 🏁 spark-k8s:4.1.2-carbon1 desplegada y cargando — c5-0-probe-motor.ts VERDE |
| ② | la credencial iría en el TEXTO del SQL | 🏁 CarbonJdbcConnectionProvider: viaja carbon.face.token, no la contraseña |
| ③ | la descomposición con compensación | ⏭️ lo único que queda |
⭐ Y el ① se cerró con una imagen custom, no con --packages: el JVM resuelve los
drivers JDBC al arrancar, así que --packages no sirve para esto. Lo documenta el
propio operador de Spark.
⛔⛔ El diagnóstico original (2026-08-16, mañana) — y la bifurcación que era FALSA
Se intentó descomponer el CTAS en la puerta. No queda limpio, y el motivo no es el CTAS. Medido:
CREATE TEMPORARY VIEW … USING jdbc OPTIONS (…, driver 'org.postgresql.Driver')
→ FAILED: java.lang.ClassNotFoundException: org.postgresql.Driver
Spark no tiene el driver de PostgreSQL. El CR carbon-connect (namespace spark)
declara exactamente dos paquetes:
--packages org.apache.iceberg:iceberg-spark-runtime-4.1_2.13:1.11.0,
com.dremio.iceberg.authmgr:authmgr-oauth2-runtime:1.1.1
⭐⭐ Y esto deshace la bifurcación «descomponer vs pivotar a 1.3.0»: ninguna de las dos desbloquea C·5. Son problemas independientes y hacían falta los dos medidos para verlo:
| qué arregla | ¿desbloquea C·5? | |
|---|---|---|
| 1.3.0 | el stage-create ⇒ CTAS entre tablas Iceberg | ⛔ no — el problema es LEER de Postgres |
| Descomponer en la puerta | evita necesitar el CTAS del catálogo | ⛔ no — el INSERT necesita que Spark hable JDBC |
| El driver en el clúster | que Spark pueda leer del origen | ✅ es el que falta |
⇒ El bloqueo de C·5 nunca fue el CTAS: es que el motor no puede hablar con PostgreSQL. Se ve al medir, no al razonar.
⚠️ Y hay un segundo bloqueo, de DISEÑO, que el driver no resuelve
La vía natural de descomposición —CREATE TEMPORARY VIEW … USING jdbc OPTIONS (…, password '…')— metería el secreto en el TEXTO del SQL que viaja al bridge y al motor.
Eso viola el invariante de C·3 (el secreto no aparece en el SQL, ni en el plan, ni en
los logs), que es justo lo que se acaba de cerrar con un gate.
⇒ Antes de C·5 hay que decidir cómo llega la credencial a Spark sin ir en la sentencia: propiedades fuera del texto en el protocolo del bridge, o que el bridge resuelva la conexión por nombre y pida él la credencial a la cara (que además la auditaría).
⏭️ Lo que C·5 necesita, en orden
- 🏁 El driver en el motor. No en el CR con
--packages—eso no funciona para JDBC— sino en una imagen custom. Verificado cargando, con control negativo. - 🏁 El transporte de la credencial. Resuelto por
CarbonJdbcConnectionProvider(§ abajo). - 🟡 La descomposición
CREATE TABLE+ carga, con su compensación. La gramática ya está (copy-sql.ts, 16 tests); falta el ejecutor y el gate.
⚠️ Y 1.3.0 pasa a ser ortogonal: mejora el camino Iceberg↔Iceberg y sigue mereciendo la pena por eso, pero no es prerrequisito de la ingesta.
⭐⭐⭐ Cómo se resolvió ②: la credencial no viaja — viaja el DERECHO a pedirla
CarbonJdbcConnectionProvider se engancha en el SPI JdbcConnectionProvider de Spark, que
llama a getConnection justo antes de abrir la conexión al origen. Ése es el único
instante en el que la credencial hace falta, así que es el único en el que existe.
Por las opciones de la sentencia viajan cuatro cosas, y ninguna es un secreto del
cliente: carbon.connection, carbon.face.url, carbon.workspace y
carbon.face.token — un token de la cara, corto, revocable y auditable. El proveedor
lo canjea contra /api/connections/credential en el momento de conectar.
| decisión | por qué |
|---|---|
canHandle sólo dice sí con carbon.connection | el SPI es global al proceso: sin esa marca el plugin secuestraría conexiones JDBC ajenas |
| no se cachea nada | una credencial cacheada convierte el TTL en un adorno; revocar CONNECTION_USE tiene que surtir efecto de verdad |
password/user/token… en las opciones se descartan | misma regla que la gramática de C·2, aplicada donde ya no se puede rechazar la sentencia |
| el motivo de la cara se propaga tal cual | dice «falta CONNECTION_USE», que es accionable; un «authentication failed» del driver mandaría a revisar la contraseña cuando el problema es un permiso |
⭐ La diferencia con poner la contraseña en SparkConf no es cosmética: allí cualquier
volcado de configuración se la lleva y la única defensa es spark.redaction.regex, que es
una lista de nombres. Un token que además hay que canjear deja el secreto del cliente fuera
del alcance de un SET -v.
⇒ El invariante de C·3 se mantiene en C·5, y no por disciplina: por construcción.
🏁 Y el cableado está MEDIDO (C·5·1) — sin necesitar ningún Postgres
scripts/ingesta/c5-1-probe-spi.ts, VERDE en las cuatro sondas. Entre «el jar está en
el classpath» (C·5·0) y «Spark instancia el proveedor y le pasa mis opciones» hay dos
cosas que fallan en silencio: que el servicio no se registre, y que Spark filtre las
opciones que no conoce. Si cualquiera fallara, la credencial tendría que volver al texto
del SQL y C·5 habría que replantearlo entero.
⭐⭐ El truco de medición: el proveedor exige carbon.face.token y lanza antes de
conectar. Así que se le manda una sentencia con carbon.connection y sin token,
contra una url deliberadamente muerta. Si vuelve nuestro mensaje, el cableado existe;
si vuelve un error de red, Spark usó el suyo. ⇒ separa el fallo del CABLEADO del fallo
de la CONEXIÓN, que es justo la distinción que un gate de punta a punta no sabe hacer —
y evita tener que exponer un Postgres al clúster para saber si el enchufe está puesto.
⛔⛔ El hallazgo: que canHandle sea selectivo NO BASTA
IllegalArgumentException: JDBC connection initiated but more than one connection
provider was found. Use 'connectionProvider' option to select a specific provider.
El proveedor básico de Spark responde a todo. Así que en cuanto el nuestro dice que sí
hay DOS, y Spark no elige: aborta. ⇒ toda sentencia de copia tiene que llevar
connectionProvider 'carbon'.
⭐ Y lo que parecía el fallo es la prueba: si Spark no hubiera descubierto nuestro proveedor sólo habría uno y no habría empate. El gate lo mide a propósito (sonda ②·a) para que nadie vuelva a leer ese error como «el plugin no está».
⚠️ El control negativo (③) sigue siendo imprescindible y por el motivo de siempre: el SPI
es global al proceso, así que un canHandle que respondiera a todo secuestraría
cualquier conexión JDBC ajena del clúster. Sin la marca, el empate no se produce.
⏭️ Lo que queda de alcance: esto es el cableado. Que la cara entregue la credencial y que la copia escriba filas es el gate de C·5 — y ése sí necesita un Postgres alcanzable desde el clúster (el de C·4 es un Docker local, que GKE no ve).
📝 La GRAMÁTICA de la copia — copy-sql.ts (16 tests)
CREATE TABLE [IF NOT EXISTS] <destino> AS SELECT * FROM <conexión>.<esquema>.<tabla> [WHERE …]
⚠️⚠️ Es la misma ambigüedad de C·4, y la misma solución. CREATE TABLE dst AS SELECT * FROM main.default.t es un CTAS del warehouse, y es legítimo. Interceptar por sintaxis
se lo quitaría, y el fallo aparecería lejos —en una pantalla que dejó de copiar tablas
internas—, no en el fichero que lo causó. ⇒ la desambiguación es de EXISTENCIA: se
extrae la forma y decide el ejecutor mirando si hay una conexión con ese nombre. Hay un
test que fija que el CTAS interno también tiene la forma, para que quede escrito que
lo único que se lo devuelve al warehouse es esa comprobación.
⭐ El filtro barato: se exigen tres segmentos en el origen. Con dos
(esquema.tabla) sería indistinguible de un CTAS interno, así que ni se consulta la base.
⛔ SELECT * y nada más: una lista de columnas se rechaza con motivo, no se
ignora. Aceptarla y copiarlo todo dejaría al usuario creyendo que proyectó en el origen —
la misma clase de fallo que aceptar un password desconocido en C·2.
⭐ El WHERE se parsea y se conserva aunque hoy la copia siempre sea completa. No es
código muerto: es la primera de las tres reservas del §7, y es la diferencia entre añadir
CDC y reescribir la copia.
🏁 C·5 · CERRADO (2026-08-16) — c5-gate-copia.ts VERDE en vivo
250 filas copiadas desde un PostgreSQL real (Cloud SQL, IP privada) al warehouse, con la suma verificada en el destino, la copia fallida sin dejar rastro, y el reintento posible. Comando en §6 del handoff.
⭐ El recuento no bastaba: se comprueba la SUMA. «Llegaron 250 filas» y «llegaron las 250 buenas» son afirmaciones distintas, y sólo la segunda dice que la copia sirve.
⭐⭐⭐ Los cuatro hallazgos, y ninguno se vio razonando
① La puerta y el motor NO tienen la misma red. La primera versión leía el esquema
conectándose al origen desde la puerta (como C·4). Falló con timeout expired contra
un Cloud SQL de IP privada que el clúster sí alcanza. Y no era del entorno de
pruebas: en producción la puerta corre en Vercel.
⇒ El esquema se le pide a quien va a mover los datos: se crea la vista JDBC, se le
hace DESCRIBE, y de ahí sale el CREATE TABLE. Tres consecuencias, y ninguna cosmética:
un solo camino de red hacia el origen (si la vista se crea, la carga puede ocurrir) · los
tipos llegan ya en Spark SQL, así que desapareció un mapa de tipos hecho a mano —
una lista que siempre está incompleta y cuyo modo de fallo natural es un default: STRING
que copia mal y no lo dice · y el fallo llega antes de crear nada, que es lo que evita
la huérfana.
⚠️ C·4 sigue conectándose desde la puerta, y ahí es correcto: navegar metadata sin motor es otra necesidad.
② El guardián del editor mata CREATE OR REPLACE por el TEXTO. Protege tablas del
Warehouse con datos dentro — y una vista temporal no es ninguna de las dos cosas. El
nombre de la vista ya lleva sufijo aleatorio, así que se quitó el OR REPLACE.
③ ⭐⭐ Una relación de la SESIÓN DEL MOTOR hay que DECLARARLA. El plan resuelve todo
nombre contra el catálogo, así que marcaba la vista temporal como tabla inexistente — y
tenía razón: no está. ⇒ RunQueryOpts.fuentesDeMotor, mismo patrón que writeTarget:
un hecho que el plan no puede deducir del texto y que por eso se declara. No abre nada —
esos nombres no se resuelven ni se reescriben, y los objetos del Warehouse siguen pasando
por la regla de siempre.
④ ⭐⭐ Se carga con MERGE, no con INSERT, y no es un rodeo. La puerta no admite
INSERT («Admitidos: DELETE · MERGE · UPDATE»), y no por descuido:
carbon-sql-contract.ts lo tiene como decisión pendiente. Ampliar la superficie de
escritura de la plataforma para que quepa una copia habría sido decidir eso de refilón.
⭐ Y MERGE no es el sustituto pobre: es la segunda reserva del §7. Con ON false
todas las filas caen en NOT MATCHED, así que hoy es un append puro; el día del
incremental sólo cambia la condición, no el camino.
⛔⛔ La IDEMPOTENCIA — el censo la daba por cubierta, y no lo estaba
CREATE TABLE IF NOT EXISTS t AS SELECT … en SQL estándar, si la tabla existe, no hace
nada. La primera implementación creaba (no-op) y cargaba igual, así que la segunda
corrida duplicaba las filas — sin un solo error.
Es exactamente lo que el censo de C·0 llamó «modo REPLACE / idempotencia» y dio por cubierto en C·5. No lo estaba, y sólo apareció al releer el censo contra lo entregado. ⇒ La lección de método: un censo que dice «lo cubre la fase X» hay que releerlo cuando X se cierra, o se convierte en una promesa que nadie comprueba.
Hoy:
| caso | qué hace |
|---|---|
la tabla existe y hay IF NOT EXISTS | no toca nada, y lo dice (rows_copied: 0, con motivo) |
la tabla existe sin IF NOT EXISTS | falla con motivo: la copia no reemplaza |
| no existe | copia |
⛔ Y no se ofrece un REPLACE implícito: sobre una tabla con datos es destructivo, y el
editor tampoco tiene TABLE_DROP — darlo aquí sería abrir por detrás lo que la puerta
niega por delante.
⭐ El gate lo mide corriendo la copia dos veces y contando: tras dos pasadas el destino sigue teniendo 250 filas. Leer el código no habría bastado — el fallo era silencioso.
⭐⭐ LOTES Y PARALELISMO — y el que casi nadie ve
fetchsize no es una optimización: es el techo de RAM. pgjdbc, por defecto,
materializa el resultado entero antes de devolver la primera fila. Sin él no hay
«streaming por lotes» — hay un OutOfMemory esperando a la primera tabla grande, y no
falla en las pruebas porque las tablas de prueba son pequeñas. Era la otra capacidad que
el censo de C·0 daba por cubierta en C·5.
El fan-out (partitionColumn + lowerBound/upperBound + numPartitions) abre N
lectores por rangos. ⭐⭐ Los extremos se le piden al ORIGEN, no a Spark: un
SELECT min/max sobre la vista podría resolverse escaneando la tabla entera —justo lo que
se evita—, mientras que mandarlo como dbtable lo ejecuta PostgreSQL, que para eso
tiene el índice.
⚠️ Y la corrección no depende de que la columna sea única: los rangos parten FILAS, no claves. Una columna repetida da particiones desiguales, no resultados malos.
⭐ Si los extremos no se pueden averiguar, se degrada a un lector en vez de abortar: el fan-out es una optimización, y cambiar «más lento» por «no funciona» sería un mal negocio.
⛔⛔ Y el umbral es configurable A PROPÓSITO (C5_FANOUT_MIN_RANGO, 100 000 por
defecto). Un umbral fijo alto haría que ningún gate ejercitara nunca el camino
particionado — y un camino que no se ejercita no está probado, está escrito. El gate lo
baja a 1 y comprueba que con 4 lectores las 250 filas y la suma siguen exactas.
⭐⭐ CREATE TABLE … LIKE <conexión>.… — metadata-only
CREATE TABLE local LIKE pg_ventas.public.ventas -- estructura, CERO filas
Sale casi gratis: el esquema ya se obtiene del motor antes de crear nada, así que esto es quitarle el paso de carga. ⭐ Y no necesita el catálogo federado temporal que el flujo canónico de Databricks crea y borra después — aquí la conexión ya es un securable.
⚠️ Misma ambigüedad y misma regla: CREATE TABLE x LIKE main.default.t es una forma
legítima del warehouse, así que la desambiguación vuelve a ser de existencia.
⭐ El gate mide las dos mitades: cero filas y cuatro columnas — una tabla vacía sin columnas también daría cero, y aprobaría por el motivo equivocado.
La compensación, y por qué el caso que vale es el que falla
compensateIfPhantom ya existía y es exactamente la primitiva: la duda no borra. No
se infiere del control-flow que la tabla no exista —un fallo puede ser un permiso, un
conflicto o un corte de red—, se le pregunta al catálogo, y sólo un 404 explícito
autoriza a retirar el registro. El gate mide las dos mitades: que no quede fila fantasma,
y que el nombre se pueda reintentar — una huérfana lo bloquearía para siempre.
🧹 C·6 · SUPRIMIR el tubo
Retirar el worker de polling y el warehouse-writer del camino de ingesta. Suprimir, no
apilar: nada de fallback que parpadea.
⚠️ El tubo está roto desde ~08-11 (el writer pide a Lakekeeper un warehouse por inquilino que ya no existe). No se arregla: se sustituye. Arreglarlo para luego borrarlo es trabajo que se tira.
Gate
c6· un trinquete en CI que falle si alguien vuelve a llamar al writer desde
el camino de ingesta, y el censo de C·0 con todas sus filas resueltas.
3 · ✅ LA DEFINICIÓN DE LISTO
Una fase está LISTA cuando cumple las siete. No hay listo parcial.
| criterio | por qué está aquí | |
|---|---|---|
| 1 | Verde en frío: tests unitarios y tsc limpio | el suelo, no el techo |
| 2 | Verde EN VIVO, contra el sustrato real, con su comando en el doc | «funciona en tests» no es funciona |
| 3 | Todo gate lleva CONTROL NEGATIVO | un gate de concurrencia sin conflicto aprueba siempre; un control que falla demasiado pronto no distingue nada |
| 4 | Se mide por la CARA, no por el upstream | preguntar a Gravitino directamente devuelve vacío sin un solo error |
| 5 | Ningún fallo silencioso: si no se escribió, el estado no dice éxito | medido tres veces el 08-16 — un job con ✅ 100 filas y cero escritas, un alta active sin miembros, un trinquete verde sobre un alta rota |
| 6 | Un trinquete que impida la regresión, y que rompa por el motivo correcto | un principio en un comentario no es un gate |
| 7 | Documento y memoria al día en la misma iteración | lo rancio es la primera fuente de confusión |
Y dos criterios de rechazo, que anulan lo anterior:
- ⛔ Un secreto que aparezca en cualquier texto persistido invalida la fase, aunque todo lo demás esté verde.
- ⛔ Una capacidad del tubo que desaparezca sin figurar en el censo de C·0 como abandonada a propósito.
4 · El orden, y por qué es ése
C·0 censo ──► C·1 securable ──► C·2 gramática ──► C·3 vending ──► C·4 SHOW ──► C·5 copia ──► C·6 suprimir
└────────── verificable en pantalla sin tocar datos ──────────┘
C·1-C·4 se pueden demostrar sin mover un byte, lo cual permite enseñar el camino antes de arriesgar nada. C·5 es el único que escribe. C·6 va al final por una razón concreta: el tubo, aunque roto, es la única documentación viva de lo que la ingesta sabe hacer.
5 · 🪤 Riesgos conocidos, con su mitigación
| riesgo | mitigación | |
|---|---|---|
| ⛔ | El INSERT falla y deja una tabla que no se puede borrar | compensación desde C·5, con el caso de fallo en el gate |
| ⛔ | La credencial acaba en el clúster o en un log | invariantes de C·3 + test que la busca |
| ⚠️ | La API de gestión de Gravitino no está expuesta (404) | ninguna fase depende de ella; el namespace se crea por IRC (POST /v1/namespaces, verificado) |
| ⚠️ | Una operación colgada envenena la sesión de Spark | medir siempre en sesión limpia; nunca encadenar medidas tras un fallo |
| ⚠️ | El destino nace en {env}_w_{tenant} (un nivel) | no volver al punto: el multinivel es lo que impedía nacer |
| ⚠️ | Suprimir el tubo antes de cubrir sus capacidades | C·0 es prerrequisito, no papeleo |
6 · Lo que este approach NO decide
Heredado de fundamentos §6, y sigue abierto:
¿La conexión se puede CONSULTAR o sólo copiar?— 🏁 DECIDIDO y entregado (F·1, 2026-08-17): sí, y es la cuña.SELECT … FROM <conexión>.<esq>.<tabla>con zero-copy medido. Cambió la tesis del producto, tal y como esta línea avisaba.- ¿El destino puede ser cualquier catálogo/esquema o siempre el namespace opaco?
- La forma del incremental.
⏭️ Y lo que la decisión ① destapó — la serie continúa en FD
📎 fd-foreign-database-approach.md
Consultar sin copiar funciona, pero se autoriza con CONNECTION_USE — el permiso de
usar la conexión—, porque no existe una tabla foránea donde colgar el SELECT. Medido
por o1-inventario-operaciones.ts:
sólo el owner alcanza una conexión, y en un inquilino un admin no puede usar las
que él mismo creó.
⇒ La entrega siguiente de esta misma iteración pone la pieza que SQL/MED, Databricks
y Snowflake tienen y aquí falta: el contenedor espejado. Aquí toma la forma de Snowflake
—FOREIGN DATABASE, database.schema.object— porque catálogo ya nombra otra cosa
en Carbon.
7 · Para no cerrarle la puerta al incremental
Sin diseñarlo, tres cosas que C·5 debe dejar preparadas —y que cuestan poco ahora y mucho después—:
- que la lectura de origen admita una condición (
WHERE cursor > …), aunque hoy no se use; - que la escritura pueda ser
MERGEy no sóloINSERT, aunque hoy siempre sea append; - que la ejecución deje rastro reanudable (qué se leyó hasta dónde), aunque hoy nadie lo lea.
⚠️ Ninguna de las tres se implementa en C·5. Se deja el sitio, que es distinto — y es la diferencia entre añadir CDC y reescribir la copia.