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 arestpara no
mentir — pero la verdad sigue siendo el runtime, no el default:/lakehouse/config.)
Regla de oro (para no volver a confundirse)
| Fuente | Es 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/config | el puente: un probe autenticado = el modo real en vivo |
| Railway dashboard | los 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)
| Servicio | Qué | Estado (act. 2026-07-31) |
|---|---|---|
| warehouse-writer | ⭐ el ESCRITOR del warehouse — el único que commitea | vivo 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-runner | cómputo Python: materialize / train / predict / llm / jobs / deploy | vivo; SERVICE_PROFILE=compute ⇒ /lakehouse/* responde 404. YA NO tiene credenciales de storage |
| Lakekeeper | catá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 Lakekeeper | vivo (read_pool/write_pool ok) |
| Keycloak | OIDC delante de Lakekeeper | vivo charming-consideration-pro… |
| karma-server | motor SQL Rust (Karma) | vivo karma-production-fbed…; INERTE (falta KARMA_SQL_URL); NO consume vended-creds aún |
| duck-server | motor SQL/DML embebido (DuckDB 1.5.4 + extensión iceberg) — y desde M2, el PARSER y el RESOLUTOR del Warehouse | vivo 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 |
| workers | el 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 workers → scripts/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, Redis | soporte | vivos |
| Supabase | INDEX — 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 descripts/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én | Credencial R2 | Alcance |
|---|---|---|
| warehouse-writer | read-write | acotada al bucket lakehouse |
| duck-server | read-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 |
| Lakekeeper | read-write (la suya propia) | ⚠️ el 4º sitio, y el que se olvida |
| trino | ⭐ NINGUNA 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 · SDK | ninguna | piden 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-serveres 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.
ml-runner: el catálogo
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 →CreateMultipartUpload400; reads →HeadObject400). Causa: la temp-cred de R2 no concede multipart (lakekeeper#1630; la Temp-Credentials API de R2 aún no soportaactions) + 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 enscripts/storage/r2-vending-spike.ts, 3/3). ⇒ La puerta acuña la llave enloadTablecon delegación, detrás deTABLE_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/ymetadata/bajo un prefijo único por tabla (s3://lakehouse/warehouse/<ns>/ds_<uuid>/…) → un cred R2 scoped a la tabla cubre ambos (validado enwriter.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 ledger | row_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_rowssoltada (mig.20261262, sinCASCADE), junto con
dataset_row_staging,dataset_branch_rowsy 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) eiceberg_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:
| Namespace | Uso | Datasets (2026-07-09) |
|---|---|---|
main.default | PROD — el estado real | 78 |
main.test | DEV / PRUEBAS (el sufijo -test a nivel de catálogo) | 6 |
bench | instrumentación (latency bench) | tooling |
datasets | LEGACY 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 enmain.default. - Ojo (no es solo el default): el namespace de escritura se resuelve del
datasets.iceberg_namespacedel 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→ camponamespace.
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.ymlml-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 porGET /lakehouse/config.