La pasarela — anatomía exacta, y la forma elegante de levantarla
Entregable (2026-07-30). Todo lo de aquí está medido contra la BD viva y el código, no citado de documentos previos. Complementa a warehouse-scaffolding-retirement.md, que enumera qué sale; esto responde a qué es exactamente lo que hay, y propone una forma de retirarlo que no es una lista de borrados.
La tesis, en una frase: la pasarela no es un conjunto de tablas. Es una pregunta hecha en el sitio equivocado, y todo lo demás son las muletas que hacen falta para poder contestarla.
⚠️ ORDEN CORREGIDO (mismo día). El owner fijó el objetivo —writes desde el SQL Editor con paridad de Snowflake/Databricks/Neon— y eso mueve a este documento del primer puesto al segundo. Declarar el substrato da
DELETE/MERGEen 15 datasets; lo que abre la matriz entera de verbos es retirar la identidad de fila, que va antes. Ver warehouse-writes-parity.md. Todo lo de aquí sigue siendo cierto — sólo cambia de puesto.
1 · La anatomía, medida
| Pieza | Qué es | Estado hoy |
|---|---|---|
dataset_rows | el plano de datos del espejo | 91 de 106 datasets viven aquí (72 con filas) |
iceberg_sync_log | el acta que cada escritor tiene que insertar | 131 filas · 1 pending colgada desde antes |
iceberg_freshness() | el oráculo: ¿el espejo va al día? | en el camino vivo de cada lectura |
icebergIdentityReady | 2º gate, sobre iceberg_sync_log.has_identity | idem |
lakehouse_read_flags · write_flags | el enrutador por puerto × dataset | 19 · 47 filas |
iceberg_sync_cursor · lakehouse_sync_partitions | maquinaria del poller | 1 fila |
dataset_transactions.status · committed_at | el índice de la ventana del oráculo | portante |
La separación es real donde llegó: 13 de los 15 datasets nativos están de verdad vacíos en dataset_rows. Pero son 15 de 106. La pasarela no es un residuo — es el régimen de la mayoría.
🔴 Y hay divergencia medida entre substratos:
fighter_jetstiene 20 filas en PG y 40 en el catálogo. Los tres puertos STRICT que siguen enoffleen de PG, así que sobre ese dataset servirían la mitad de los datos, sin error. Los «45 pares expuestos» de W·2 dejan de ser un producto cartesiano y tienen al menos un caso real.
2 · La pregunta, y por qué está mal puesta
El oráculo pregunta:
«¿Ha alcanzado Iceberg el último estado commiteado de Postgres?»
Es una pregunta sobre una relación entre dos substratos, y presupone que Postgres es la fuente y que Iceberg la sigue. Para un dataset espejado es la pregunta correcta. Para un dataset nativo no tiene referente: no hay nada que seguir, dataset_rows está vacío.
Y sin embargo se sigue haciendo. Y los escritores nativos la contestan fabricando la respuesta.
Medido, para los seis nativos muestreados: caught_up = true, y lo es porque hay exactamente una fila de iceberg_sync_log con status='succeeded'. Esa fila la inserta el propio escritor nativo — iceberg-native-write.ts:14 lo documenta sin rodeos en su paso 4:
«record the snapshot in
iceberg_sync_logas 'succeeded' — this is what makes the Stage 0b read-seam oracle report the dataset caught-up»
Y el porqué de que sea obligatoria y fail-loud, también escrito, en :306-309: «sin esta fila, iceberg_freshness no puede reportar caught_up NUNCA para esta txn, así que los lectores STRICT quedarían clavados en PG — que para un dataset nativo está VACÍO. Devolver éxito propagaría una mentira de frescura.»
Léelo despacio: una escritura nativa registra que «una sincronización tuvo éxito» cuando no hubo ninguna sincronización. No es un bug — es la única forma de que el oráculo diga que sí. El escritor nativo se disfraza de espejo para pasar por una puerta que sólo entiende de espejos.
De ahí sale, en cascada, todo lo demás: el acta obligatoria por escritor, las dos tablas de flags para poder decidir por puerto y por dataset, y los tres gates encadenados que cada lectura atraviesa.
Cada motor nuevo cuesta una migración por esto, y no por Iceberg.
3 · El hallazgo: la columna ya existe, y nunca aprendió a decir «iceberg»
datasets.storage_backend lleva su contrato escrito desde el principio en la cabecera de dataset-writer.ts:36-37:
* datasets.storage_backend = 'postgres' (default) → PostgresBackend
* datasets.storage_backend = 's3' → S3Backend (future)
Es exactamente la declaración que hace falta: dónde viven los bytes de este objeto. Medido hoy: vale postgres (95) o supabase_storage (11) — 106 de 106, ni un NULL. Y ya la consume el selector de backend de escritura (dataset-writer:1796). Nunca aprendió a decir iceberg.
(El propio repo la declara NOT NULL DEFAULT 'postgres' en un comentario de dataset-writer:1822; la columna no nace en ninguna migración trackeada, así que eso es evidencia documental + 0 NULLs medidos, no el information_schema.)
Lo que pasó es que, faltando esa palabra, el sistema construyó un ORÁCULO que calcula en cada lectura lo que una columna tenía que DECLARAR una vez. El oráculo, el acta, los flags y los tres gates son la infraestructura de esa inferencia. La pasarela es el precio de no haber escrito una palabra en una columna que ya existía.
4 · La forma elegante: declarar el substrato en el nacimiento
El substrato deja de inferirse y pasa a declararse, en la única columna que ya existía para eso, y en el único sitio que ahora puede estamparlo: la puerta.
HOY PROPUESTO
─── ─────────
leer → ¿mode? (flags) leer → ¿storage_backend? (una columna)
→ ¿caught_up? (oráculo) → ejecutar
→ ¿has_identity? (acta)
→ ejecutar (o caer al otro lado)
Y esto sólo es posible desde esta semana. Antes de I·2 no había un sitio donde estampar el substrato al nacer: había 21 nacimientos repartidos por el repo, y cinco de ellos eran copias envejecidas del mismo cuerpo. Ahora hay una puerta, que además ya valida tenencia y ya parte el carrier. Estampar storage_backend es una línea más en la firma que ya existe.
Las tres piezas que caen, y por qué caen SOLAS
| Pieza | Por qué deja de hacer falta |
|---|---|
| el oráculo, para nativos | la pregunta «¿va al día el espejo?» no se hace sobre un objeto que no tiene espejo |
el acta (iceberg_sync_log), para nativos | sólo existía para que el oráculo pudiera contestar. Sin oráculo, el escritor deja de tener que disfrazarse |
icebergIdentityReady, para nativos | la identidad de una tabla nativa es por construcción (writer.py antepone siempre las tres columnas). El gate existe porque una tabla ESPEJADA podía ser pre-Fase-2 |
Ninguna se borra. Las tres quedan ACOTADAS a los datasets espejados, que es donde significan algo — y ahí siguen siendo correctas.
⭐ Y el bonus, que es el argumento más fuerte
Los 45 pares expuestos de W·2 —lectores STRICT que pueden caer a un Postgres vacío— no se arreglan flipeando flags: dejan de poder existir.
Hoy el router, ante un fallo, cae al otro substrato. Eso tiene sentido mientras los dos tengan el dato. Con el substrato declarado, un objeto iceberg no tiene otro lado al que caer: el fallback deja de ser una degradación y pasa a ser lo que siempre fue para un nativo — servir datos incompletos en silencio. La respuesta correcta es un error, y es la que sale sola.
fighter_jets(20 en PG vs 40 en el catálogo) es la demostración. Constorage_backend='iceberg'nadie puede servir las 20.
5 · Por qué esto no es un big-bang: la máquina se queda sin sujetos
La propiedad que hace elegante a este plan es que no hay un día de la mudanza.
- Cada objeto que nace por la puerta nace con su substrato declarado.
- Cada dataset que se migra a nativo se declara al migrarse.
- El oráculo, el acta y los flags siguen funcionando exactamente igual para los que aún no lo están.
La pasarela no se demuele: se queda sin población. El día que ningún dataset declare postgres, las tablas se pueden borrar sin coordinar nada — porque ya no las mira nadie. Es la misma forma que N·1 dio al namespace opaco («los dos regímenes conviven sin coordinación»), aplicada al substrato.
6 · Lo que NO cae, dicho en voz alta
Un plan que sólo enumera lo que gana es una presentación de ventas.
dataset_rowsno se va con esto. Sigue siendo el substrato real de 91 datasets. Esto declara dónde viven; no los mueve.lakehouse_write_flagsno lo subsume todavía. El substrato declarado es la respuesta natural también para la escritura, pero el orden tiene que ser lectura primero: un error de lectura se ve; uno de escritura se descubre después.- La ventana incremental sigue dependiendo de
status/committed_at(W·3). Este entregable no la roza. - La divergencia de
fighter_jetsno se arregla sola. Declarar el substrato impide servirla mal; no reconcilia las 20 filas huérfanas. Hay que decidir si se borran o se investigan. - No hay ninguna medición de latencia. Sustituir dos consultas (flags + oráculo) por una lectura de columna debería ser más rápido; no está medido y no se afirma.
7 · Fases
Cada una tiene guarda propia y es reversible sola.
P·0 · Enseñarle la palabra a la columna (inerte)
storage_backend acepta 'iceberg'. La puerta lo estampa al nacer. Nadie lo lee todavía. Guarda: un test de la puerta.
⚠️ Medido: la columna no nace en ninguna migración del repo —precede al tracker, como dataset_rows—, así que su dominio real (¿hay CHECK?) hay que leerlo del information_schema de la BD viva antes de escribir el valor nuevo. Es el mismo runbook del drift de migraciones.
P·1 · El censo y el backfill declarativo (sólo escritura de metadata)
Estampar 'iceberg' en los datasets que ya son nativos. Es derivable del sello que el reconciliador ya usa (metadata->>sink = 'iceberg_native') y son 15. Guarda: un harness que compare, dataset a dataset, «lo que dice la columna» contra «lo que dice el oráculo». Cero divergencias es el criterio de paso.
P·2 · El router lee la columna, en SOMBRA
resolveDatasetRowSource calcula el substrato por los dos caminos y sirve por el viejo, registrando las discrepancias. Es la misma forma que N·2b: medir antes de apostar. Criterio de parada escrito: si la columna y el oráculo discrepan en algún dataset servido, la tesis se cae y hay que entender por qué antes de seguir.
P·3 · La columna manda, por puerto y con canario
Los 3 puertos que hoy están en off primero — donde retirar la compuerta arregla algo (los 45 pares). Los 5 que ya sirven Iceberg, después.
P·4 · Los escritores nativos dejan de disfrazarse
Retirar la inserción de iceberg_sync_log del camino nativo. Es el paso que devuelve la honestidad: deja de registrarse una sincronización que no ocurrió. Sólo después de P·3, porque hasta entonces el oráculo aún la lee.
P·5 · Acotar y esperar
Oráculo, acta y flags quedan restringidos a storage_backend='postgres'. No se borra nada. Se borrará cuando el censo de P·1 dé cero.
8 · Riesgos
- El substrato declarado puede mentir, igual que mentía
has_identity. Mitigación: lo estampa sólo la puerta, que es un sitio con trinquete (check:dataset-births), y P·2 lo contrasta contra el oráculo antes de creérselo. - Un objeto migrado a nativo y no re-declarado se seguiría sirviendo de PG. Es el modo de fallo nuevo, y es detectable: es exactamente lo que compara el harness de P·1, que puede correr periódicamente.
- Quitar el fallback endurece el sistema. Un fallo de ml-runner deja de degradar y pasa a fallar. Es lo correcto —degradar servía datos incompletos— pero es un cambio de perfil operativo y hay que decirlo antes, no descubrirlo.
iceberg_sync_logtiene una filapendingcolgada desde antes de esta sesión. Antes de acotar la tabla conviene saber si hay un barrido que la cierre o si nadie la mira.
9 · Decisiones para el owner
- ¿
storage_backendes la columna, o se creasubstrate? Recomendación:storage_backend. Su contrato ya es «dónde viven los bytes», estáNOT NULLcon default y ya lo consume el selector de backend de escritura. Crear una columna nueva al lado de una que significa lo mismo es cómo se llegó aquí. - ¿El fallback desaparece o se degrada a error explícito? Recomendación: error explícito, con el motivo tipado. Un nativo sin ml-runner no es un dataset lento: es un dataset ilegible, y decirlo es más barato que servir la mitad.
- ¿Qué se hace con
fighter_jets? Recomendación: investigar antes de declarar su substrato. 20 filas huérfanas en PG frente a 40 en el catálogo es o un sync a medias (incidente) o DML no replicado (estructural, y entonces reordena esto).
Cruza con: warehouse-scaffolding-retirement.md (el plan W, que esto reencuadra) · warehouse-index.md (el pilar que se queda) · index-frontier.md (control ⊥ datos) · index-i2b-approach.md (la puerta que lo hace posible) · INFRA.md.