Published

INFRA · Topología del runtime (única fuente de verdad)

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

INFRA · Topología del runtime (única fuente de verdad)

Por qué existe este fichero. El repo NO es el runtime desplegado: prod se
configura con env vars en Railway, invisibles al repo. Leer solo el repo (o los
defaults del código, o lo committeado como docker-compose sin vars lakehouse)
lleva a conclusiones falsas ("Lakekeeper no está en pie"). Este doc declara
qué corre dónde y en qué modo; se actualiza cuando cambia la topología.
(P2.5: los defaults de catálogo del código ya se alinearon a rest para no
mentir — pero la verdad sigue siendo el runtime, no el default: /lakehouse/config.)

Regla de oro (para no volver a confundirse)

FuenteEs verdad de…
El sistema vivo (Railway)el ESTADO (datos, catálogo, qué modo corre)
El repo (este doc + .env.example)el CÓDIGO y el CONTRATO (nombres de vars + valores de MODO)
GET /lakehouse/configel puente: un probe autenticado = el modo real en vivo
Railway dashboardlos SECRETOS (y solo ahí)

Nunca deduzcas el modo de prod del default del repo. Haz el probe.

Cómo leer el estado en vivo (1 comando cada uno)

# ¿En qué modo corre el ml-runner? (catalog_type, warehouse, vending, namespace, auth)
curl -sS -H "Authorization: Bearer $ML_RUNNER_TOKEN" "$ML_RUNNER_URL/lakehouse/config"

# ¿Lakekeeper vivo?
curl -sS https://heroic-victory-production-41dd.up.railway.app/health

Topología (Railway = prod)

ServicioQuéEstado (act. 2026-07-31)
warehouse-writerel ESCRITOR del warehouse — el único que commiteavivo warehouse-writer-production…; mismo código que ml-runner con SERVICE_PROFILE=warehouse (sólo monta el router lakehouse). Lleva la credencial R2 read-write; ninguna clave de LLM
ml-runnercómputo Python: materialize / train / predict / llm / jobs / deployvivo; SERVICE_PROFILE=compute/lakehouse/* responde 404. YA NO tiene credenciales de storage
Lakekeepercatálogo Iceberg REST (quay.io/lakekeeper/catalog:v0.13.0)vivo heroic-victory-production-41dd…/catalog; /health→200; warehouse lakehouse poblado (namespaces datasets/bench/main.default/main.test)
Postgres (lakekeeper)metastore propio de Lakekeepervivo (read_pool/write_pool ok)
KeycloakOIDC delante de Lakekeepervivo charming-consideration-pro…
karma-servermotor SQL Rust (Karma)vivo karma-production-fbed…; INERTE (falta KARMA_SQL_URL); NO consume vended-creds aún
duck-servermotor SQL/DML embebido (DuckDB 1.5.4 + extensión iceberg) — y desde M2, el PARSER y el RESOLUTOR del Warehousevivo duck-production-ef4a…; sirve el tráfico del SQL Editor por la puerta (F4a) y ejecuta el DML gobernado (F4b). ⭐ M0/M2 (2026-07-31): extensiones pineadas con manifiesto verificado en el arranque · timeout de ejecución (query 120s → 504, turno 20s → 503) · el parser del motor decide read/write y su AST resuelve los nombres contra la rebanada autorizada (resolve_mode=enforce) · contrato de dialecto declarado y verificado sobre un cursor al arrancar · GET /shadow expone las dos mediciones. ⭐ P1 (2026-08-05, DESPLEGADO Y VERIFICADO EN VIVO): pool de cursores (DUCK_MAX_CONCURRENT_QUERIES, def. 8) — hasta aquí servía a TODOS los workspaces con una query concurrente por un lock nuestro, no por DuckDB. Medido en prod: 6 simultáneas en 0,83 s contra 0,62 s una sola (serializadas, ~3,74 s), y un SELECT * real devolviendo filas de R2 con la llave read-only. interrupt() es por-cursor ⇒ un atasco cuesta 1 slot de N (y se recupera), así que wedged ya sólo significa «perdidos todos»; /health publica concurrencia y storage_credential
workersel cluster de workers de Node — polling de fuentes, schedulers del lakehouse (ratify, retención, reconciliador), colas⚠️ el repo declara el servicio pero INFRA no lo nombraba. Se construye con Dockerfile.workers y arranca npm run workersscripts/start-workers.ts. Es el servicio donde viven TODAS las env-vars ENABLE_LAKEHOUSE_* y INDEX_BIRTH_DOOR_POLLING — no el frontend de Vercel ni ml-runner. El nombre exacto del servicio en Railway hay que leerlo del dashboard: identifícalo por su build source (Dockerfile.workers) o su start command (npm run workers)
Postgres-wk_K, Redissoportevivos
SupabaseINDEX — el pilar 3: gobernanza, linaje, metadata (ver abajo)prod; sello D2+D6b aplicado aquí directo. Sin tracker de migraciones → el schema vivo DERIVA de supabase/migrations y puede driftar: léelo con scripts/dataspaces/audit-migration-drift.ts, no con los ficheros (runbook)

⭐ El layer SQL, tras Carbon SQL M0-M2 (2026-07-31)

Una sola autoridad sintáctica: el parser del motor. Se acabaron los dos
clasificadores heurísticos que debían coincidir. Node descubre y autoriza las
tablas (la gobernanza va aguas arriba); el motor parsea, resuelve y ejecuta.

El contrato de superficie se genera midiendo (docs/warehouse-sql/carbon-sql-v0.md,
salida de scripts/duckdb/carbon-sql-conformance.ts), nunca se escribe a mano. Plan y
estado: architecture/carbon-sql-milestones.md;
contrato: architecture/warehouse-sql-dialect-spec.md.

La puerta: ningún servicio de arriba se alcanza directamente desde una superficie. Junction (lib/compute/junction.ts) es la puerta única y agnóstica de motor: resuelve identidad → puntero → modo → frescura → gobernanza, y selecciona qué motor ejecuta. Ver architecture/junction.md.

⭐ El PERÍMETRO del storage — quién puede tocar R2 (act. 2026-07-31)

El storage tiene un perímetro: sólo el cómputo entra. Todo lo demás pide por la puerta.

QuiénCredencial R2Alcance
warehouse-writerread-writeacotada al bucket lakehouse
duck-serverread-only(verificado 2026-08-05, no supuesto)Token R2 propio, acotado al bucket lakehouse. Pasado por el control negativo scripts/storage/duck-ro-credential-gate.ts → 6/0: lee, y PutObject/DeleteObject dan AccessDenied. ⚠️ Hasta ese día la variable _RO_ tenía el mismo valor que la RW y el log decía «perimetro B»: el perímetro se daba por hecho. Ahora el arranque compara las dos llaves y /health publica storage_credential
Lakekeeperread-write (la suya propia)⚠️ el 4º sitio, y el que se olvida
trinoNINGUNA PERMANENTE (desde 2026-08-04)La pide en cada loadTable y la acuña la puerta: acotada a ESA tabla y con caducidad, sólo si tiene TABLE_READ_DATA. Verificado con el motor: SELECT en main.default ✅ · en main.test ✗ · grep '^s3.aws-'0. TRINO_R2_ACCESS_KEY_ID/_SECRET revocables. Ver architecture/storage-tenancy-approach.md
ml-runner · Next · workers · SDKningunapiden por la puerta

2026-08-04 · el perímetro dejó de ser una lista de llaves. Trino fue el primero
en salir: no lleva credencial de R2, la recibe por petición y caduca. El camino para
los demás es el mismo — duck-server es el siguiente candidato, y el día que el writer
escriba por la puerta, también él. Lo que queda con llave permanente hoy:
warehouse-writer (RW), duck-server (RO), Lakekeeper (la suya), y el token padre del
vending, que vive en Vercel porque la puerta es quien acuña
y es el activo más sensible
de la plataforma.

⚠️ Eran CINCO sitios, no tres (Trino entró el 2026-08-02: la rotación que se olvide de él deja el perfilado de OpenMetadata muerto en silencio). El propio catálogo guarda una storage-credential y la usa para validar y operar sobre R2 al crear/commitear tablas. Rotar sin actualizarla rompe TODA escritura aunque los servicios tengan llaves buenas — pasó el 2026-07-31, y el síntoma es engañoso: LIST tables responde 200 (sólo catálogo) mientras cualquier commit da S3 ... Unauthorized. Se actualiza con POST /management/v1/warehouse/{id}/storage-credential.

Vigilado por npm run check:storage-perimeter (en CI). Ver architecture/storage-perimeter.md.

PROD corre LAKEHOUSE_CATALOG_TYPE=rest → Lakekeeper (confirmado por las env vars de Railway + /health en vivo). El contrato completo de env está en services/ml-runner/.env.example.

P2.5 · SqlCatalog RETIRADO como modo de runtime. El default del código se alineó a rest (3 sitios en writer.py), y la rama SqlCatalog de build_catalog quedó como elif LEGACY con else: raise (fail-loud ante un type desconocido). Lo único que aún la mantiene viva es el tool de adopción one-shot (register_service.py / scripts/register_tables.py, ya consumido = break-glass). La eliminación física de la rama + del DSN iceberg_catalog Postgres se hará al desmantelar ese Postgres.

  • Vending (LAKEHOUSE_REST_VENDING): OFF — y debe seguir OFF (diferido). Prod corre metadata-only: Lakekeeper gobierna el catálogo, el FileIO usa la llave R2 estática (LAKEHOUSE_S3_*). Se probó =1 (2026-07-09) y rompió READS y WRITES: R2 rechaza las creds que vende Lakekeeper (writes → CreateMultipartUpload 400; reads → HeadObject 400). Causa: la temp-cred de R2 no concede multipart (lakekeeper#1630; la Temp-Credentials API de R2 aún no soporta actions) + probable storage-profile sin R2 Admin token válido; los overrides de cliente son inertes (el config del catálogo manda). 🔴 CORREGIDO 2026-08-04 — y la distinción es la lección: lo que está roto es el vending de LAKEKEEPER contra R2, NO la Temp-Credentials API de R2. Llamada directamente acota por prefijo, caduca, y el acotado aísla (control negativo en scripts/storage/r2-vending-spike.ts, 3/3). ⇒ La puerta acuña la llave en loadTable con delegación, detrás de TABLE_READ_DATA (lib/governance/storage-vending.ts). Remote-signing deja de hacer falta. Se midió una implementación y se concluyó sobre una capacidad.
  • El layout de tablas pone data/ y metadata/ bajo un prefijo único por tabla (s3://lakehouse/warehouse/<ns>/ds_<uuid>/…) → un cred R2 scoped a la tabla cubre ambos (validado en writer.py:494).

Los tres pilares (antes «el tridente»)

FUENTE ──sync/ingest──▶ ml-runner ─┬─ ① BYTES (Parquet)     ──▶ R2  (s3://lakehouse/warehouse/…/data|metadata)
                                    └─ ② PUNTERO (metadata.json current, CAS) ──▶ LAKEKEEPER (su Postgres)

app Next.js / workers ──────────────── ③ INDEX ─────────────────▶ SUPABASE PG

③ INDEX = el pilar de gobernanza (nuestro Unity Catalog). Y dentro de él la distinción que este diagrama antes ocultaba, porque metía todo bajo «METADATA»:

Lo que es de Index (se queda)Lo que es ANDAMIO (sale)
schema SSOT, tier, la coordenada catalog.schema.name, linaje, procedencia/dataspace, permisos, ontología, versiones de esquema, y la intención del ledgerrow_count, size_bytes, column_count, current_transaction_id, status/last_sync_at

🏁 El PLANO DE DATOS de Postgres ya no existe (2026-07-31)

dataset_rows soltada (mig. 20261262, sin CASCADE), junto con
dataset_row_staging, dataset_branch_rows y el cortejo de control
(lakehouse_read_flags · write_flags · read_parity · iceberg_sync_cursor).
PG queda como Index: gobernanza. El dato vive en Iceberg + Parquet.

Dos de aquella lista de «andamio» resultaron no serlo y se quedan:
iceberg_sync_log (hoy es el ledger de operaciones del JOP) e iceberg_freshness()
(conserva el uso legítimo «¿aterrizó mi escritura?»). Ver
architecture/legacy-plane-removal.md.

La regla: Index guarda lo que el catálogo no puede saber; lo que el catálogo sabe, se le pregunta. Inventario completo en architecture/warehouse-index.md; el plan de retirada, en architecture/warehouse-scaffolding-retirement.md.

⚠️ row_count está hoy TRIPLICADO (datasets + dataset_transactions + iceberg_sync_log) y lo estampan 28 sitios vivos. Es el ejemplo canónico de andamio: el catálogo ya lo tiene en el total-records del snapshot.

Endpoints que disparan el tridente, hoy en warehouse-writer (no en ml-runner): /lakehouse/ingest (native) · /lakehouse/ingest-parquet · /lakehouse/stage. Todos sobre el catálogo resuelto por catalog_config_from_env().

⚠️ POST /lakehouse/sync ya NO existe. Era el ESPEJO que copiaba dataset_rows al lakehouse; se borró con su worker en D·1c. Un 200 ahí significa que estás contra un build viejo. Node resuelve el writer con WAREHOUSE_WRITER_URL ?? ML_RUNNER_URL (lib/lakehouse/runner-fetch.ts).

Dev vs prod (política: prod = verdad, dev por namespace)

Una sola FORMA de runtime (rest→Lakekeeper); dev/prod se separan por namespace, no por otra config. Convención en el warehouse lakehouse:

NamespaceUsoDatasets (2026-07-09)
main.defaultPROD — el estado real78
main.testDEV / PRUEBAS (el sufijo -test a nivel de catálogo)6
benchinstrumentación (latency bench)tooling
datasetsLEGACY pre-D3 (congelado; se retira con D3.2)
<NULL>sin iceberg_namespace (PG-only / no materializadas)13

La convención ya es real en los datos (78 prod / 6 dev). ⚠️ Trampa latente: el env de prod es LAKEHOUSE_NAMESPACE=datasets (fallback plano legacy), no main.default — una escritura sin namespace resuelto caería en datasets. FIX (1 env-var en Railway): LAKEHOUSE_NAMESPACE=main.default. El estado real se ve en /lakehouse/config.

  • Dev/pruebas = mismo Lakekeeper, LAKEHOUSE_NAMESPACE=main.test → aislado de prod, cero infra nueva. Nunca mezclar en main.default.
  • Ojo (no es solo el default): el namespace de escritura se resuelve del datasets.iceberg_namespace del dataset (born-in-place D3), no de un default de código; 'datasets' es fallback de último recurso. Por eso la separación dev/test se hace por env explícito (main.test), NO cambiando ese default (redirigiría datasets legacy no-pinneados al namespace de test).
  • ¿En qué namespace estoy? GET /lakehouse/config → campo namespace.

Decoys retirados ✅ (2026-07-09)

  • Spike SQLite local — BORRADOS scripts/iceberg_spike.py, scripts/chunked_ingest_smoke.py, scripts/requirements-spike.txt, app/main_lakehouse.py, Dockerfile.lakehouse, requirements-lakehouse.txt. La TERCERA forma (catálogo SQLite) que despistaba ya NO existe en el repo.
  • services/ml-runner/.env.lakehouse — los scripts EDC/dataspaces migrados a leer las creds R2 de .env.local (services/edc-service/*.sh, scripts/dataspaces/*), así que el fichero ya NO tiene ninguna referencia en el repo. ⚠️ Acción del operador: borrar el fichero local + rotar las claves R2 (estuvieron filtradas en chat).
  • docker-compose.yml ml-runner — no setea vars lakehouse a propósito (sirve compute local, no el camino lakehouse); NO es un decoy. El modo real siempre se lee por GET /lakehouse/config.