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.
| Paso | Línea | ¿Comprueba { error }? |
|---|---|---|
| 1 · abrir txn | :179-191 | ✅ if (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:
- El snapshot se commitea en R2 y en el catálogo. Hecho irreversible.
- Ledger →
committed.iceberg_sync_log→succeeded. Stats aplicadas. recordSchemaVersionlanza.- El
catchmarca la txnaborted.
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 soncommittedo "no lo sé".
abortedqueda 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_transactionsno tieneCREATE TABLEen migraciones (precede al tracker) → tocar su constraint es justo lo que no queremos hacer con prisa. - Las txns huérfanas
openya 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 falla | Estado del ledger | Se devuelve | Se alerta |
|---|---|---|---|
| Paso 1 (abrir) | — (no hay txn) | throw | no |
Paso 2 (ingest) con fallo demostrable | aborted | throw | no |
| Paso 2 ambiguo (timeout/red) | open | throw | sí — el snapshot puede existir |
| Paso 3 (commit ledger) | open | throw | sí |
Paso 4 (sync_log) | queda committed, sin sync_log | throw | sí — frescura incoherente |
| Paso 5 (stats) | committed | éxito + warn | sí |
recordSchemaVersion | committed | éxito + warn | warn |
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 (
abortedcon snapshot vivo;succeededen sync_log colgando de una txn no-committed) contra un doble desupabaseAdmin. 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
tryen las dos fases y retirar elabortedincondicional. - B·3 · Alertas — un log de incidente distinguible (no un
warnmás) por cada estado ambiguo, con eltxnIdy el paso. - B·4 · Detector de inconsistencia — extender
scripts/dataspaces/iceberg-mirror-exposure.ts(o hermano) para censar en la BD viva: txnsabortedconsync_log succeeded, txnsopenviejas, ysync_logsin 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)
- 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 darows_affected; ml-runner no expone el snapshot-log). Mientras siga así, «ambiguo» solo puede resolverse a mano. - 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.
- El constraint real de
dataset_transactions.statuses 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)
- ✅
opencomo «no lo sé» (sin estado nuevo, sin migración). - ✅ Un fallo del paso 4 (
sync_log) LANZA. - ✅ 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ón | Resultado |
|---|---|
① txns aborted con sync_log succeeded | 0 |
② txns nativas committed sin sync_log | 0 (de 15 nativas) |
③ txns open rancias | 0 |
④ sync_log con txn inexistente | 28 (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
aborteddesapareció 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.recordSchemaVersionaislado en su propiotry/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
warnmá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.