Published

B1/B2 — Reparar el control-plane nativo antes de construir sobre él

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

B1/B2 — Reparar el control-plane nativo antes de construir sobre él

Entregable de iteración (2026-07-28). Prerequisito duro de JOP y de F4b. No es refactor ni preparación: son dos bugs vivos en la primitiva por la que ya pasa toda la escritura nativa de Carbon (5 writers, defaultMode=iceberg_native).

Se escribe aparte porque tiene una propiedad rara y valiosa: arreglarlos correctamente ES escribir el primer invariante de JOP. No es un desvío del camino — es su primer metro.


0 · Por qué esto va antes que el paradigma

withNativeControlPlane (iceberg-native-write.ts:156) es la única primitiva de escritura nativa. Todo lo que JOP propone —ratify, UNKNOWN, "el estado terminal no se infiere del control-flow"— se implementa ahí dentro.

Construir el 4º data-plane (DuckDB) encima de estos dos bugs tiene un coste concreto: haría indistinguible un fallo nuevo de uno viejo. Cuando un write de DuckDB deje el ledger inconsistente, nadie podrá decir si es el motor nuevo o el defecto que ya estaba.


1 · B1 — Tres de las cuatro escrituras no comprueban su error

El docblock de la función promete: «On any error the ledger txn is aborted and the error rethrown (fail-loud)». Es cierto solo para la apertura del ledger y para el data-plane.

PasoLínea¿Comprueba { error }?
1 · abrir txn:179-191if (txnErr || !txn) throw
2 · data-plane (ingest):196✅ (dentro del try)
3 · commit del ledger:201-209
4 · iceberg_sync_log:212-225
5 · stats de datasets:259

supabase-js no lanza: resuelve con { data, error }. Un fallo de constraint, FK o RLS en los pasos 3/4/5 se traga entero y la función devuelve un NativeWriteResult de éxito.

El paso 4 es el caro. Es lo que hace que iceberg_freshness reporte caught-up. Si falla en silencio: el snapshot está durable en R2, el ledger dice committed, y el oráculo nunca se entera → los lectores STRICT quedan clavados en PG… que para un dataset nativo tiene dataset_rows vacío.


2 · B2 — aborted se escribe desde el catch, sin saber qué pasó

} catch (err: any) {
  await supabaseAdmin.from('dataset_transactions')
    .update({ status: 'aborted' }).eq('id', txnId)
    .then(() => {}, () => {});          // :281-288
  throw err;
}

Ese catch cubre todo el try — incluidos los pasos posteriores al commit del snapshot. Y hay un lanzador real ahí dentro: recordSchemaVersion (:265) es un RPC que lanza (no error-return).

Secuencia realista:

  1. El snapshot se commitea en R2 y en el catálogo. Hecho irreversible.
  2. Ledger → committed. iceberg_sync_logsucceeded. Stats aplicadas.
  3. recordSchemaVersion lanza.
  4. El catch marca la txn aborted.

Estado resultante: una transacción aborted con su snapshot vivo, sus stats aplicadas, y una fila iceberg_sync_log con status='succeeded' apuntándola.

Consecuencia aguas abajo, que es la que muerde: la ventana incremental filtra por status='committed', así que las filas de esa transacción quedan escritas pero invisibles para todo consumidor delta. Y el llamante recibe una excepción —"la escritura falló"— cuando los datos están puestos, así que reintentará: en modo APPEND, duplicando.


3 · La causa raíz es UNA, y ya tiene nombre

Los dos bugs son la misma equivocación:

Se está infiriendo un HECHO del mundo (¿existe el snapshot?) a partir del CONTROL-FLOW de un proceso (¿llegué al catch?).

El control-flow no lo sabe. El único que lo sabe es el catálogo.

De ahí sale el invariante que arregla los dos y que JOP adopta tal cual:

Una vez el data-plane ha tenido éxito, la transacción NO PUEDE volver a aborted. Sus únicos destinos son committed o "no lo sé".

aborted queda reservado a los fallos en los que se puede demostrar que nada aterrizó.


4 · El diseño de la reparación

4.1 · Dos fases explícitas, no un try monolítico

FASE A · antes/durante el data-plane   → un fallo puede marcar `aborted`
────────────────────────────── el snapshot existe ────────────────────────────
FASE B · registrar el hecho            → un fallo NUNCA marca `aborted`

La frontera es el retorno de ingest(). Cruzarla es irreversible, y el código debe leerlo así.

4.2 · «No lo sé» se representa dejando la txn open

No se añade un estado nuevo. open ya significa exactamente eso —abierta, sin desenlace— y ya está fuera de todos los filtros que importan: iceberg_freshness exige status='committed', y la ventana delta filtra por committed_at IS NOT NULL.

Ventajas sobre inventar 'unknown':

  • Cero migración, y dataset_transactions no tiene CREATE TABLE en migraciones (precede al tracker) → tocar su constraint es justo lo que no queremos hacer con prisa.
  • Las txns huérfanas open ya son una condición monitorizada en el healthz del SDK. La reconciliación tiene dónde engancharse.

4.3 · Qué hace cada fallo, exactamente

Dónde fallaEstado del ledgerSe devuelveSe alerta
Paso 1 (abrir)— (no hay txn)throwno
Paso 2 (ingest) con fallo demostrableabortedthrowno
Paso 2 ambiguo (timeout/red)openthrow — el snapshot puede existir
Paso 3 (commit ledger)openthrow
Paso 4 (sync_log)queda committed, sin sync_logthrow — frescura incoherente
Paso 5 (stats)committedéxito + warn
recordSchemaVersioncommittedéxito + warnwarn

Los pasos 5 y el schema-version son genuinamente best-effort (no afectan a correctitud de datos ni al oráculo) — pero ahora se comprueban y se registran en vez de desaparecer.

4.4 · El paso 2 ambiguo: la disciplina de Delta

Un timeout en el data-plane no significa que no aterrizó. Delta lo formaliza como CommitStateUnknownException y su regla es: no limpiar, no reintentar a ciegas, no borrar metadata. Hoy Carbon hace lo contrario.

Distinguir «falló seguro» de «no se sabe» exige señales del motor que hoy no tenemos (ver §6). Regla interina, conservadora: solo se marca aborted cuando el fallo ocurre antes de emitir la petición al data-plane. Cualquier fallo con la petición ya emitida ⇒ open + alerta.


5 · Plan

  • B·0 · Red de seguridad primero — un test que reproduzca los dos estados imposibles (aborted con snapshot vivo; succeeded en sync_log colgando de una txn no-committed) contra un doble de supabaseAdmin. Debe fallar ANTES del arreglo.
  • B·1 · Comprobar los errores de los pasos 3/4/5, con la política de §4.3.
  • B·2 · Partir el try en las dos fases y retirar el aborted incondicional.
  • B·3 · Alertas — un log de incidente distinguible (no un warn más) por cada estado ambiguo, con el txnId y el paso.
  • B·4 · Detector de inconsistencia — extender scripts/dataspaces/iceberg-mirror-exposure.ts (o hermano) para censar en la BD viva: txns aborted con sync_log succeeded, txns open viejas, y sync_log sin txn committed. Mide si el daño ya ocurrió.
  • Gate: tsc + tests verdes; el detector de B·4 corrido contra la BD real y su resultado registrado.

Fuera de alcance, deliberadamente: exportar withNativeControlPlane, cablear DuckDB, y la reconciliación automática. Esto repara; JOP construye.


6 · Lo que queda abierto (y que JOP hereda)

  1. No podemos preguntar «¿aterrizó la MÍA?» — no hay operation_id/commit-uuid en ninguna escritura, y ninguna primitiva devuelve el snapshot que creó (duck-server solo da rows_affected; ml-runner no expone el snapshot-log). Mientras siga así, «ambiguo» solo puede resolverse a mano.
  2. Node no tiene cliente Iceberg REST — todo acceso al catálogo pasa por ml-runner, así que el proceso que necesita reconciliar no puede consultar la autoridad.
  3. El constraint real de dataset_transactions.status es desconocido (la tabla no está en migraciones). Verificar antes de asumir que admite algo distinto de open/committed/aborted.

7 · Decisiones — ✅ RATIFICADAS Y EJECUTADAS (2026-07-28)

  1. open como «no lo sé» (sin estado nuevo, sin migración).
  2. Un fallo del paso 4 (sync_log) LANZA.
  3. El detector se corrió ANTES.

8 · Resultado

El censo previo (scripts/dataspaces/control-plane-consistency.ts, BD real)

861 txns · 113 filas de sync_log:

ComprobaciónResultado
① txns aborted con sync_log succeeded0
② txns nativas committed sin sync_log0 (de 15 nativas)
③ txns open rancias0
sync_log con txn inexistente28 (esperable: son de las otras dos tablas de ledger)

El arreglo no congela nada — solo impide que se produzcan. Y el ruido de base de open es CERO, así que la señal nueva nace limpia: a partir de aquí, toda txn open rancia es accionable.

Lo implementado

  • aborted desapareció de la primitiva. No es que se evite en algún camino: es que ninguno lo escribe, porque la función nunca está en posición de demostrar que nada aterrizó. El barrido de tests falla cada paso uno a uno y comprueba justo eso.
  • recordSchemaVersion aislado en su propio try/catch — era el lanzador que producía B2.
  • Los tres pasos comprueban { error }, con la política de §4.3.
  • ControlPlaneUnrecordedError: dice que los datos ya están y que no se reintente a ciegas.
  • Log de incidente distinguible por paso, no un warn más.
  • Dos docblocks que habían quedado mintiendo, corregidos.

Verificación: tsc 0 · 179/179 · 8 tests nuevos del invariante · censo contra la BD real.

Lo que este arreglo deja listo para JOP

El invariante de §3 es el invariante 3 de JOP («ningún estado terminal se infiere del control-flow»), y open es su UNKNOWN. Cuando JOP añada ratify —preguntarle al catálogo qué pasó de verdad— tendrá dónde engancharse: las txns open son exactamente su cola de trabajo.


Entregable de iteración. Repara la primitiva sobre la que JOP se construirá. Cruza con: junction.md · junction-f2-engine-adapter.md §2·ter · f4-governed-dml-approach.md.