Published

Junction F2 — abstracción de motor (EngineAdapter) + substrato ⊥ motor

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

Junction F2 — abstracción de motor (EngineAdapter) + substrato ⊥ motor

Entregable de F2 (2026-07-10) · ✅ IMPLEMENTADO 2026-07-28. Companion de junction.md + junction-execution-map.md. F2 = la capa-2 de Furnace: convertir el binario 'postgres'|'iceberg' (que MEZCLABA substrato y ejecutor) en dos ejes limpios, y dar a cada motor (DuckDB / Karma / pyiceberg / PG) un adaptador intercambiable detrás de la puerta. Inert-first: se estableció el contrato + los adaptores, sin reescribir los ~30 call-sites. El primer consumidor real = runQuery (F4).

Estado: F2.0 + F2.1 + F2.2 hechos, tsc 0, 1030 + 12 tests verdes. Dos premisas del plan original resultaron FALSAS al contrastarlas con el código — corregidas abajo (§2·bis y §3·bis). Siguiente = F3 (harness diferencial).


1 · El modelo afinado: substrato ⊥ motor

El hecho que F2 corrige (verificado en read-router.ts:54,109 y write-router.ts:64,118): el enum 'postgres'|'iceberg' es el SUBSTRATO (dónde viven los bytes), y el MOTOR que ejecuta el substrato iceberg es hoy implícito — el closure iceberg que CADA caller pasa a run<T>({postgres, iceberg}) llama siempre a ml-runner (read-client/iceberg-native-write).

SUBSTRATO (dónde viven los bytes)     MOTOR (quién computa)
  pg       → JSONB en dataset_rows      →  pg   (la query PG del caller — legacy, en retiro)
  iceberg  → Parquet/Iceberg en R2      →  { mlrunner | duckdb | karma }   ← PLURAL

La clave: la pluralidad de motor vive en el substrato iceberg — los tres motores hablan Iceberg sobre la misma cintura (Lakekeeper + FQN física). PG es el substrato legacy con su propio motor (la query JSONB). Así, EngineAdapter abstrae los motores de iceberg; el motor pg queda como el closure legacy que se retira a medida que los datasets pasan a nativo.

Substrate (pg|iceberg) y Engine (mlrunner|duckdb|karma|pg) ya existen como tipos (añadidos en F1, lib/compute/contract.ts). F2 los CONECTA al código.


2 · El contrato EngineAdapter (el corazón de F2)

Un adaptador por motor, detrás de la MISMA frontera. Es lo que runQuery (stub de F1) despachará:

⚠️ La firma que este §2 proponía originalmente era FALSA (ponía runQuery en el contrato base). Se conserva el diagnóstico, pero la firma de abajo es la REAL, la implementada; el porqué está en §2·bis. Si lees §2 y paras, implementas mal.

// lib/compute/engine-adapter.ts — LA FIRMA REAL (implementada 2026-07-28)
export interface EngineCapabilities {
  substrate: Substrate;                 // sobre qué substrato opera (iceberg | pg)
  sql: boolean;                         // ¿acepta SQL de usuario? ⇔ es SqlEngineAdapter
  read: boolean;                        // ¿ejecuta lecturas?
  write: boolean;                       // ¿ejecuta escritura (DML o ingest)?
  dialect: 'duckdb' | 'substrait' | 'none';   // (NO existe ningún dialecto 'spark')
  available: boolean;                   // ⊥ capacidades: ¿cableado en ESTE deployment?
  unavailableReason?: string;
  features: readonly string[];          // whitelist DERIVADA por el harness (F3)
}

export interface EngineAdapter {          // el contrato BASE: identidad + capacidades
  readonly engine: Engine;
  capabilities(): EngineCapabilities;     // ⚠️ lee process.env EN CADA LLAMADA (no cachear)
}

export interface SqlEngineAdapter extends EngineAdapter {   // EXTENSIÓN: los que sí
  runQuery(req: QueryRequest, opts?: { signal?: AbortSignal }): Promise<QueryResult>;
}
export function isSqlEngine(a: EngineAdapter): a is SqlEngineAdapter;

Adaptadores (ninguno inventa un motor; envuelven lo YA construido — o solo declaran):

EngineQué es el adapter en realidadEstado
duckdbWrapper REAL de duck-client (duckQuery, write_target, envelope tipado de F4.1). Delgado en lectura; en escritura NO — ver el aviso de §2·ter.✅ implementado
mlrunnerSolo declaración de capacidades (sql:false). NO envuelve read-client/iceberg-native-write: esa cara ya la consume el composer, y re-envolverla sería una capa hueca sin consumidor.✅ implementado
karmaDeclaración con available atado a KARMA_SQL_URL (hoy ausente ⇒ no seleccionable). Sin runQuery hasta acoplar.inerte por diseño
pgDeclaración del substrato legacy (sql:false — es la query JSONB del propio caller, no un servicio).legacy; se retira con el substrato pg

2·ter · ⚠️ El adapter duckdb NO cierra el control-plane en escritura (prerequisito de F4)

RESUELTO (2026-07-29) — esta sección es registro histórico. F4b cableó DuckDB como 4º data-plane por la puerta (532bb52; junction.ts:1209): el DML abre ledger, inserta iceberg_sync_log y estampa stats, con fail-loud (ControlPlaneUnrecordedError) en las dos escrituras obligatorias.

Dos matices que la sección de abajo no podía anticipar. (1) El razonamiento del split-brain se corrigió: el oráculo no cambia tras un write de DuckDB; el peor modo de fallo real es que un sync.full posterior lo oblitere. (2) Cerrar el control-plane no hace atribuible al motor: la txn se abre con attributable:false porque duck-server no estampa snapshot_properties, así que ratify no puede rescatarla. Ese es hoy el hueco vivo — ver warehouse-scaffolding-retirement.md §7.

Llamar al adapter "un wrapper delgado" es exacto en lectura y engañoso en escritura. engines/duckdb.ts devuelve un CommitResult que refleja lo que el motor commiteó en el catálogo (el CAS de Lakekeeper), pero no toca el control-plane: no registra dataset_transactions, no actualiza datasets.row_count ni el sync log.

Consecuencia si F4 enruta escrituras por ahí tal cual: el oráculo de frescura (icebergFreshness → RPC iceberg_freshness, que lee justo esas tablas) seguirá diciendo "no caught up" después de una escritura exitosa → los lectores STRICT caerán a PG indefinidamente, y PG quedará como proyección desfasada de un Iceberg que sí avanzó. Split-brain silencioso.

Por qué no se resolvió en F2: la primitiva que lo hace bien es withNativeControlPlane (lib/lakehouse/iceberg-native-write.ts:156) y no está exportada — su contrato es un control-plane, N data-planes con el data-plane como closure. Cablear DuckDB como 4º data-plane es trabajo de F4/F5 (f4-governed-dml-approach.md §2.3), no algo que un adaptador deba improvisar. F2 es inerte, así que hoy no lo ejecuta nadie; queda anotado en el punto exacto del código para que F4 no lo descubra en producción.

El registry lib/compute/engines/index.ts mapea Engine → EngineAdapter. runQuery selecciona el adapter por la política de selección (capa-3, hoy: iceberg→duckdb para live-SQL; F4).


2·bis · ⚠️ CORRECCIÓN — runQuery NO puede ser un método del contrato base

La firma de arriba (§2) da por hecho que los cuatro motores implementan runQuery(sql). Al contrastarla con el código real (2026-07-28) resultó falsa en dos de los cuatro:

  • mlrunner NO tiene endpoint SQL. Su superficie son ops estructuradas/lakehouse/read, /aggregate, /ingest, /ingest-parquet, /checksum, /plan-files, /register… (verificado en services/ml-runner/app/routers/lakehouse.py). No existe /query ni /sql. El doc lo listaba como "✅ construido — wrapper", pero lo construido es un cliente de ops estructuradas, no un ejecutor de SQL.
  • pg no es un servicio al que mandar SQL. Es la query JSONB que ejecuta el propio caller sobre dataset_rows. SQL de usuario ahí no significa nada — ese fue exactamente el shim que F3-RETIRO borró (dialect.ts/translateQuery/jsonbCast). Reintroducirlo por la puerta de atrás sería revertir F3.

Un runQuery universal que lanzara en 2 de 4 motores es una capa hueca — justo lo que junction.md §9·principio 12 prohíbe. Forma implementada:

// lib/compute/engine-adapter.ts
export interface EngineAdapter {                    // el contrato BASE: identidad + capacidades
  readonly engine: Engine;
  capabilities(): EngineCapabilities;
}
export interface SqlEngineAdapter extends EngineAdapter {   // EXTENSIÓN: los que sí aceptan SQL
  runQuery(req: QueryRequest, opts?: { signal?: AbortSignal }): Promise<QueryResult>;
}
export function isSqlEngine(a: EngineAdapter): a is SqlEngineAdapter;

Así el tipo dice la verdad sobre qué puede cada motor y runQuery (F4) solo despacha a los que la tienen. mlrunner/pg declaran sql:false y no cargan un método que lanzaría.

Tercer eje añadido — available ⊥ capacidades. Karma puede leer SQL nativo pero hoy no está cableado (falta KARMA_SQL_URL). Mezclarlo con sql haría que un motor no desplegado mintiera sobre sí mismo. Separarlos permite que la capa-3 degrade con criterio: EngineCapabilityError ("no sabe hacerlo" → prueba otro motor) vs EngineUnavailableError ("no está desplegado" → reintentar con el mismo no sirve).

export interface EngineCapabilities {
  substrate: Substrate;   sql: boolean;   read: boolean;   write: boolean;
  dialect: 'duckdb' | 'substrait' | 'none';
  available: boolean;     unavailableReason?: string;      // ⊥ de las capacidades
  features: readonly string[];   // vacío: se puebla con el harness de F3, no por fe
}

Matriz real de motores (implementada):

Enginesubstratesqlreadwritedialectavailable (hoy)
duckdbicebergduckdbsii DUCK_SERVER_URL
mlrunnericebergnonesii ML_RUNNER_URL+_TOKEN
karmaiceberg✅ (al cablear)substrait❌ (KARMA_SQL_URL ausente)
pgpgnone✅ (la BD de control)

3 · Touch-points EXACTOS (inert-first)

Tipos (lib/compute/contract.ts):

  • FreshnessVerdict.source: 'postgres'|'iceberg' (:102) → Substrate (rename de valor postgrespg).
  • CommitResult.sink: 'postgres'|'iceberg' (:267) → Substrate.

Routers (mantienen su rol: resuelven SUBSTRATO; ganan el campo engine):

  • read-router.ts: DatasetRowSource.source (:54) → Substrate; añadir engine: Engine al resuelto (default pg si substrato pg, port.engine ?? 'mlrunner' si iceberg). run<T> (:63,151) — firma sin cambio en F2 (los closures {postgres, iceberg} siguen; el engine viaja en el resolved para observabilidad + para que runQuery sepa a qué adapter ir).
  • write-router.ts: DatasetRowSink.sink (:64) → Substrate; añadir engine: Engine. Idem run<T> (:74,135).
  • junction.ts (composer): los sink: 'iceberg' (:623,635) → alinear al Substrate + estampar engine. .run<T>({postgres, iceberg}) (:533,579,603) sin cambio.

Registro (port-registry.ts): ReaderPort/WriterPort ganan engine?: Engine (hoy NUNCA declaraban motor — el gap de capa-3). Default: iceberg→mlrunner. capabilities?: EngineCapabilities por puerto NO se implementó, deliberadamente: las capacidades son del MOTOR, no del puerto, y duplicarlas por puerto sin harness sería declarar por fe dos veces.

Nuevo: lib/compute/engine-adapter.ts (el contrato) + lib/compute/engines/{duckdb,mlrunner,karma,pg}.ts (adaptores) + lib/compute/engines/index.ts (registry). Todo inerte — nada los llama hasta runQuery (F4).

Migración 'postgres''pg': rename de valor mecánico, verificable por tsc, en los 6 sitios del enum + comparaciones (=== 'postgres', === 'iceberg'). Limpia el legacy que mezclaba los ejes.


3·bis · ⚠️ CORRECCIÓN — el blast-radius del rename NO eran 6 ficheros, y tsc NO lo blindaba entero

El plan decía "6 sitios, mecánico, verificable por tsc". Lo real (barrido exhaustivo, 2026-07-28) fueron 14 ficheros, y —lo importante— dos de ellos tsc NO los detecta:

Los que tsc SÍ delata (comparación con el tipo → TS2367), y por tanto eran seguros: lib/compute/contract.ts (:103 source, :268 sink) · read-router.ts (:54,109,156) · write-router.ts (:64,118,139) · junction.ts (:485,496) · lib/pipelines/transformPaths.ts:1404 · app/api/pipelines/[id]/manual-table/route.ts:149,175,196 · scripts/dataspaces/pipeline-output-canary.ts:167 · los tests read-router.test.ts (×4), write-router.test.ts (×3), sql-source-hydration.test.ts (×2).

Los que tsc NO delata — el riesgo real de un "rename mecánico":

  1. components/workspace/tabs/warehouse/WarehouseTab.tsx:107sourceLabel(src?: string) comparaba src === 'postgres' con el parámetro tipado string, no Substrate. Tras el rename habría degradado en silencio a '—' en la UI del Warehouse ("Data source", 2 sitios + el badge del sample). Arreglado con un mapa exhaustivo Record<Substrate, string>: si Substrate cambia, ahora rompe en tsc.
  2. app/api/dataspace/item/sample/route.ts:45 — devuelve source: handle.freshness.source por el wire; es passthrough (no rompe), pero confirma que el valor del substrato es observable fuera del proceso.

Lo que sí se verificó y hacía el rename seguro: el valor NO se persiste en ningún sitio — no hay columna de BD, ni metadata JSONB, ni flag que lo guarde. La única salida no-UI es un log estructurado (lib/workers/polling/dataset-writer.ts:923). Sin persistencia no hay migración de datos ni ventana de skew.

Homónimos que NO se tocaron (mismo string, otro dominio — renombrarlos habría roto cosas de verdad): lib/cdc/source-profiles.ts (CdcMechanism), datasets.storage_backend='postgres' (dataset-writer.ts), los dialectos de lib/handshake/** (SqlDialect), lib/canvas/helper/credentials.ts, lib/ai/capability-resolver.ts.

Lección para las fases siguientes: "verificable por tsc" solo vale donde el valor viaja tipado. En las fronteras donde se degrada a string (UI, wire, logs) hay que re-tipar la frontera — no confiar en el compilador.


4 · Las 2 decisiones de F2

  1. ¿Reescribir los run<T> ahora, o solo establecer el contrato?

    • Recomendado — inert-first: F2 ESTABLECE SubstrateEngine en los tipos + EngineAdapter + registry + adaptores (wrappers de los clientes existentes) + el campo engine en el resolved, SIN tocar los ~30 caller-closures de run<T>. Primer consumidor = runQuery (F4). Los readers/writers estructurados migran a adaptores mucho después (o nunca — siguen con sus closures; el adapter es para el path runQuery/live-SQL). Cero riesgo.
    • ❌ Eager: reescribir los ~30 run<T> a un mapa keyed-por-motor ahora = gran churn, riesgo, poco valor inmediato (el único motor de iceberg hoy es ml-runner).
  2. 'postgres''pg': ¿migrar el valor del enum en F2 (alinear a Substrate, limpiar el legacy) o dejar 'postgres' y solo añadir el eje engine? Recomendado: migrar (es la limpieza que el owner pidió; tsc lo blinda). Coste: rename mecánico en 6 ficheros.


5 · Plan de F2 (inert-first, verificable) — ✅ EJECUTADO

  • F2.0 · tipos ✅: Substrate conectado a source/sink (rename postgrespg) en los 14 ficheros (§3·bis) + engine añadido a DatasetRowSource/Sink. tsc 0.
  • F2.1 · EngineAdapter + registry ✅: lib/compute/engine-adapter.ts (contrato base + SqlEngineAdapter + isSqlEngine + los 2 errores + bindingOverlaySchema) y lib/compute/engines/{duckdb,mlrunner,karma,pg}.ts + index.ts (registry TOTAL Record<Engine,…> → añadir un motor sin adaptador rompe en tsc). duckdb = wrapper REAL de duck-client (mapea QueryRequestDuckQueryOpts y DuckQueryResultQueryResult, incl. el envelope de escritura de F4.1 y la derivación de ensureNamespace desde el writeTarget); mlrunner/pg/karma = declaración honesta de capacidades, sin wrapper hueco. Test engines/index.test.ts (12 casos).
  • F2.2 · estampar engine en el resolved ✅: ambos routers estampan engine (substrato pgpg; icebergspec.engine ?? 'mlrunner'), y ReaderPort/WriterPort ganan engine?: Engine — hasta ahora un puerto nunca declaraba motor (el gap de capa-3).
  • Gate ✅: cero cambio de comportamiento; tsc 0; 1030/1030 tests del seam + 12/12 del registry. Nada sirve por adapter hasta que runQuery (F4) lo consuma.

Deuda que F2 NO cerró (deliberado): capabilities().features va vacío en los 4 motores — poblarlo sin el harness diferencial sería declarar por fe. Es exactamente el trabajo de F3.


6 · Cómo encaja con lo ya construido

F2 no construye motores — envuelve los clientes que ya existen: el adapter duckdb es un wrapper de duck-client (que ya hace query tipado + write_target + el clasificador de F4.2); el mlrunner envuelve read-client + iceberg-native-write (el control-plane de escritura). Así, cuando runQuery (F4) despache por EngineAdapter, reutiliza TODO el stack de F3-RETIRO/F4 sin reimplementar. El EngineAdapter es la junta que convierte "duck-server es un servicio que llamo a pelo" en "DuckDB es un motor intercambiable detrás de la puerta".


Doc vivo. F2 = capa-2 de Furnace (abstracción de motor), inert-first — implementado 2026-07-28 con las 2 correcciones de §2·bis/§3·bis. Siguiente: F3 (equivalence harness → puebla features) → F4 (absorber sql-editor por runQuery, el primer consumidor de SqlEngineAdapter).