Published

El paradigma de escritura — la imagen completa antes de migrar los escritores

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

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:

FormaQué haceMedido hoy
PG crudoINSERT INTO dataset_rows a pelo21 bypasses catalogados + 39 ficheros con conteo fijado (el ratchet los vigila)
SeamresolveDatasetRowSink(writerId) decide pg / iceberg_native por dataset11 puertos de escritura registrados · 45 call-sites en 17 ficheros
Nativo directowriteIcebergNative* / ingestDatasetToIceberg15 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 registro lib/lakehouse/port-registry.ts
  • Puerta → lib/compute/junction.ts (contrato en lib/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:

EjeQué te daQuién lo tiene
Ruteado (seam)Se puede flipear por dataset sin tocar código11 puertos / 17 ficheros
Por la puerta (facade.write())Un solo plan; el motor lo decide la puerta1 (el brazo nativo de pipelines)
Gobernado (JOP)Atribuible, ratificable, compensablelos 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.

PropiedadQué significaCómo se consiguió
Atribuible«¿aterrizó la MÍA?» tiene respuestaEl txnId del ledger viaja dentro del commit Iceberg (carbon.operation-id). Cero identificadores nuevos
RatificableEl desenlace lo dicta el catálogo, no la memoria del procesoratify + POST /lakehouse/operation-status; ya con scheduler (dry-run + cortacircuitos)
CompensableEl estado anterior se puede fijar y sobrevivecompensate pone un tag, y la expiración respeta los tags por el motor, no por convención
Con linajeUn replace ya no borra la historiadelete-all + add_files en una Transaction (un solo CAS)
AcotadaConservar historia tiene techoPolí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. aborted solo lo escribe quien puede demostrarlo (ratify), porque desde un catch no 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:

  1. La gobernanza JOP viene incluida — la puerta envuelve el control-plane; nadie tiene que acordarse de abrir el ledger.
  2. La atribución no se puede perder — el bug del fan-out era estructuralmente posible porque el brazo lo escribía el llamante.
  3. 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

TierQuiénesEstadoQué les falta
0 · ya nativossync.full/incremental/append, cdc.append, ingest.api, EDC landing, manual.tableescriben Iceberg por defectomover su brazo detrás de la puerta (borrarlo)
1 · nativos apagadospipeline.output + transform-paths (transform/join/split/model-node)brazo completo, defaultMode=pgcanary por dataset; ya hay harness
2 · bypasses catalogados21 ficheros en la allowlist del ratchetPG crudo, vigiladola mayoría es control-plane / construcciones PG; el ratchet ya aprieta solo
3 · sin primitivaingest.stream, row.editRAW-PG, diferidosnecesitan streaming open/commit y DELETE puntual → DuckDB/Karma
N/Adataset.manifest, sdk.files.listPG por diseñonada: 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.
  • ratify nunca ha ejercido aborted en 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. ratify solo barre la primera.
  • dataset_transactions no tiene CREATE TABLE en 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).