El paradigma de escritura — la imagen completa antes de migrar los escritores
Entregable (2026-07-29). Foto medida del estado real, no del que dicen los trackers. Existe para responder una pregunta concreta: ¿qué significa exactamente «migrar un escritor», de forma que hacerlo 30-y-pico veces salga limpio, escalable y centralizado en vez de multiplicar el caos que se quería quitar?
Todos los números de aquí salen de contar en el código a día de hoy, no del tracker port-migration.md (act. 2026-07-09, anterior a Junction y a JOP).
1 · De dónde venimos: por qué la escritura era difusa
El problema original no era que unos escritores fueran legacy y otros no. Era que había tres formas distintas de escribir, sin relación entre ellas, y «estar migrado» significaba cosas diferentes según a quién le preguntaras:
| Forma | Qué hace | Medido hoy |
|---|---|---|
| PG crudo | INSERT INTO dataset_rows a pelo | 21 bypasses catalogados + 39 ficheros con conteo fijado (el ratchet los vigila) |
| Seam | resolveDatasetRowSink(writerId) decide pg / iceberg_native por dataset | 11 puertos de escritura registrados · 45 call-sites en 17 ficheros |
| Nativo directo | writeIcebergNative* / ingestDatasetToIceberg | 15 ficheros |
Y ninguna de las tres podía responder la pregunta que importa cuando algo va mal: «¿esta escritura mía llegó a ocurrir?»
2 · Lo que sostiene una escritura hoy: las capas reales
Los ficheros que SON cada capa:
- Seam →
lib/lakehouse/write-router.ts+ el registrolib/lakehouse/port-registry.ts - Puerta →
lib/compute/junction.ts(contrato enlib/compute/contract.ts) - JOP →
lib/lakehouse/iceberg-native-write.ts(withNativeControlPlane, 3 entry-points: snapshot, fan-out, parquet) - Verbos →
lib/compute/ratify.ts·lib/compute/compensate.ts
3 · El hallazgo: son TRES ejes independientes, no un camino
«Migrado» se usa hoy para tres propiedades distintas que un escritor puede tener por separado:
| Eje | Qué te da | Quién lo tiene |
|---|---|---|
| Ruteado (seam) | Se puede flipear por dataset sin tocar código | 11 puertos / 17 ficheros |
Por la puerta (facade.write()) | Un solo plan; el motor lo decide la puerta | 1 (el brazo nativo de pipelines) |
| Gobernado (JOP) | Atribuible, ratificable, compensable | los 3 entry-points nativos |
Un escritor puede estar ruteado y no gobernado (el brazo PG no abre ledger). Puede estar gobernado y no pasar por la puerta (los nativos llaman al control-plane directamente). Por eso la foto se ve difusa: no hay un único sitio por el que pase todo.
El detalle que decide si la migración escala
El seam no escribe: devuelve un objeto con .run({ postgres, iceberg }) y el que pone las dos implementaciones es el llamante.
// lib/workers/cdc/sink.ts — el patrón que hoy significa "migrado"
const sink = await resolveDatasetRowSink('cdc.append', datasetId);
if (sink.sink === 'iceberg') {
// …30 líneas escritas a mano: leer schema, armar el iterador, llamar al nativo
await writeIcebergNativeSnapshot({ … });
} else {
// …el INSERT de siempre
}
Migrar un escritor hoy = escribirlo por segunda vez, contra Iceberg, a mano.
Eso es O(N) de lógica de escritura duplicada. Con 30-y-pico escritores no da una plataforma centralizada: da treinta y pico implementaciones de la misma idea, cada una con su propia forma de equivocarse. Y no es una hipótesis — ya pasó: el brazo nativo del fan-out recibía el operationId del control-plane y lo tiraba, así que ninguno de sus commits era atribuible y ratify no podía confirmar ni uno. Nadie lo vio hasta que se preguntó al catálogo.
4 · Lo que estas dos sesiones dejaron construido
No son features sueltas: son las propiedades que una escritura puede tener ahora y que antes no existían. Son la base de la que depende que la migración valga la pena.
| Propiedad | Qué significa | Cómo se consiguió |
|---|---|---|
| Atribuible | «¿aterrizó la MÍA?» tiene respuesta | El txnId del ledger viaja dentro del commit Iceberg (carbon.operation-id). Cero identificadores nuevos |
| Ratificable | El desenlace lo dicta el catálogo, no la memoria del proceso | ratify + POST /lakehouse/operation-status; ya con scheduler (dry-run + cortacircuitos) |
| Compensable | El estado anterior se puede fijar y sobrevive | compensate pone un tag, y la expiración respeta los tags por el motor, no por convención |
| Con linaje | Un replace ya no borra la historia | delete-all + add_files en una Transaction (un solo CAS) |
| Acotada | Conservar historia tiene techo | Política declarada en la tabla (history.expire.*) + job que la aplica |
Y dos invariantes que ahora sostiene el código, no la disciplina:
- Ningún estado terminal se infiere del control-flow.
abortedsolo lo escribe quien puede demostrarlo (ratify), porque desde uncatchno se distingue «no aterrizó» de «aterrizó y no supe apuntarlo». - El namespace no se asume, se resuelve. Mordió tres veces (la cintura, la atribución, la retención); ahora vive en un único sitio,
lib/lakehouse/dataset-namespace.ts.
5 · Qué debería significar «migrado» — la decisión
La forma de que los 30-y-pico salgan limpios es colapsar los tres ejes en uno: que el escritor declare QUÉ quiere escribir y la puerta decida CÓMO.
Con eso, «migrar un escritor» pasa a ser quitarle su brazo de Iceberg, no dárselo. Y tres cosas dejan de poder olvidarse:
- La gobernanza JOP viene incluida — la puerta envuelve el control-plane; nadie tiene que acordarse de abrir el ledger.
- La atribución no se puede perder — el bug del fan-out era estructuralmente posible porque el brazo lo escribía el llamante.
- El motor deja de ser asunto del escritor — DuckDB, pyiceberg o Karma se eligen detrás de la cintura, sin tocar 30 ficheros.
El coste, dicho claro: hoy la puerta escribe por un camino (facade.write(), rows/parquet) y su brazo PG lanza a propósito (frontera del paso 6). Colapsar los ejes exige que la puerta cubra el espectro real de escritura —replace/append/upsert, delta incremental, dual-target del split— antes de que se le pueda pedir a un escritor que suelte el suyo. Eso es trabajo de puerta, no de escritores, y es el prerequisito de la migración, no un detalle posterior.
6 · El orden que sugiere la medición
| Tier | Quiénes | Estado | Qué les falta |
|---|---|---|---|
| 0 · ya nativos | sync.full/incremental/append, cdc.append, ingest.api, EDC landing, manual.table | escriben Iceberg por defecto | mover su brazo detrás de la puerta (borrarlo) |
| 1 · nativos apagados | pipeline.output + transform-paths (transform/join/split/model-node) | brazo completo, defaultMode=pg | canary por dataset; ya hay harness |
| 2 · bypasses catalogados | 21 ficheros en la allowlist del ratchet | PG crudo, vigilado | la mayoría es control-plane / construcciones PG; el ratchet ya aprieta solo |
| 3 · sin primitiva | ingest.stream, row.edit | RAW-PG, diferidos | necesitan streaming open/commit y DELETE puntual → DuckDB/Karma |
| N/A | dataset.manifest, sdk.files.list | PG por diseño | nada: son control-plane file-backed |
Y hay una guarda que ya existe y conviene no perder: scripts/check-dataset-rows-seam.ts es un ratchet — un fichero nuevo no puede tocar dataset_rows salvo que pase por el seam o entre en la allowlist (y eso se ve en review). Hoy pasa: 1722 ficheros escaneados, y avisa cuando la lista puede encogerse.
7 · Lo que sigue abierto
- La puerta no cubre el espectro de escritura (§5). Es el prerequisito real.
- Expirar no libera espacio — la retención acota historia, no la factura de R2. Ver junction-replace-lineage.md §7.1.
ratifynunca ha ejercidoaborteden vivo — hacerlo exigiría fabricar una transacción que no ocurrió en un log de auditoría.- Tres tablas de ledger (
dataset_transactions,sdk_dataset_transactions,pipeline_build_transactions) unidas solo por una vista.ratifysolo barre la primera. dataset_transactionsno tieneCREATE TABLEen migraciones (drift): existe en producción por historia, pero un entorno nuevo no la tendría.
Cruza con: junction.md (qué es Junction) · junction-handoff.md (estado vivo) · junction-replace-lineage.md (linaje + retención) · port-migration.md (tracker por puerto, más antiguo).