DuckDB detrás de Junction, sirviendo N warehouses — el enrutado que falta
APPROACH (2026-08-05), no ejecución. Sucede a storage-tenancy-approach.md
(P1-P4, que cerró el aislamiento de almacenamiento: un warehouse en Lakekeeper por
organización, con sukey-prefix, verificado en vivo hoy con tres organizaciones reales)
y a duckdbengine.md (la decisión de motor: DuckDB = cómputo+DML,
detrás de Junction). Ninguno de los dos resuelve esto:duck-serversigue atado a UN
solo warehouse fijo por variable de entorno (LAKEHOUSE_REST_WAREHOUSE=lakehouse), así
que el trabajo de P1 existe en Lakekeeper pero el motor interactivo no lo usa. Este doc es
el enrutado que falta entre los dos.Cruza con: warehouse-authorization.md ·
compute-integration.md ·
lib/lakehouse/ingest-client.ts ·
lib/warehouse/query/duck-client.ts
1 · El síntoma, medido hoy
Comparando los tres clientes Node que hablan con el lakehouse:
| Cliente | Resuelve tenantWarehouseNameForDataset | Manda warehouse en la petición |
|---|---|---|
lib/lakehouse/ingest-client.ts:71 (ml-runner, escritura de pipelines) | ✅ | ✅ |
lib/lakehouse/read-client.ts:95 (ml-runner, lectura) | ✅ | ✅ |
lib/warehouse/query/duck-client.ts (duck-server, SQL Editor) | ❌ | ❌ — el campo no existe en el body |
El JWT que duck-client.ts firma solo lleva workspace_id — de tenencia, para RLS y
auditoría. duck-server, del lado servidor, no lo traduce a un warehouse: sigue con el
único que tiene fijo por variable de entorno (lakehouse, key-prefix: warehouse, el
histórico de antes de P1). Resultado medible: si hoy escribes o lees por el SQL Editor de
la organización "test 1" (que ya tiene su warehouse propio, w_cbe70f74…, verificado activo
con permisos sembrados), el motor no se entera — sigue sirviendo desde el warehouse
compartido, como si P1 no existiera.
Esto no es un bug de hoy: es la mitad que duckdbengine.md §7.2 ya marcó como abierta —
"DuckDB ata un catálogo por CONEXIÓN y ahora hay un warehouse por inquilino, así que una
conexión no puede servir a dos organizaciones sin re-atar. Es un approach, no una
variable." Este documento es ese approach.
2 · La pregunta que hay que contestar antes de tocar código
¿Cómo sirve un proceso de cómputo compartido y sin estado (duck-server) a N
inquilinos con warehouses distintos, si el mecanismo de aislamiento del motor (ATTACH)
es con estado?
No es una pregunta nueva — es exactamente el problema que resuelven, cada uno a su manera, Databricks (Unity Catalog), Trino/Presto multi-tenant y cualquier pool de conexiones JDBC por-inquilino. La respuesta no está en nuestro código: está publicada, y por eso tocaba investigarla antes de diseñar nada.
3 · Lo que dice la industria, con fuentes
3.1 · DuckDB SÍ soporta múltiples catálogos adjuntos a la vez
Verificado contra la documentación de DuckDB y el extension duckdb-iceberg
(Iceberg REST Catalogs ·
DeepWiki: Catalog Attachment):
- Un mismo proceso/sesión de DuckDB puede tener varios catálogos Iceberg REST adjuntos
simultáneamente, cada uno con su propio alias (
ATTACH … AS tenant_a,ATTACH … AS tenant_b). - Una vez adjunto, las queries no vuelven a autenticar — se referencian por FQN
(
tenant_a.schema.tabla). Cambiar de inquilino dentro de una sesión ya adjunta es gratis: es solo qué alias usa la query. - Lo que SÍ tiene coste es el
ATTACHen sí (intercambio OAuth2 +GET /v1/configpara resolver elprefixdel warehouse — la misma llamada que haceupstreamPrefix()en nuestra cara hoy). Ese coste es por warehouse nuevo, no por query.
⇒ El modelo correcto no es "una conexión por inquilino" — es "un catálogo adjunto
por warehouse, cacheado, con el resto de queries reutilizándolo". Encaja mejor con lo que
duckdbengine.md ya decidió (duck-server es cómputo stateless, no una BD local por
tabla) que con re-conectar por request.
3.2 · Lakekeeper mismo lo prescribe así, no es una lectura nuestra
De su propia documentación (Lakekeeper · Storage):
"Never share locations between Warehouses to ensure no data is leaked via vended
credentials."
Y confirma el patrón de despliegue: un deployment de Lakekeeper sirve múltiples
Warehouses, y los motores de cómputo se conectan especificando cuál — no es una excepción
nuestra, es el uso previsto. Un solo cliente OIDC (el mismo lakekeeper-duck-server que ya
existe y ya tiene el audience mapper) puede autenticar contra cualquier warehouse del
proyecto — hoy authz-backend: allow-all, así que no hace falta ni tocar permisos para
que funcione a nivel de autenticación.
3.3 · El precedente de Databricks Unity Catalog — un metastore, N catálogos
(Unity Catalog architecture · Unity Catalog overview)
Un solo metastore (una sola conexión lógica de gobierno) expone múltiples catálogos, y
un mismo clúster de cómputo puede resolver contra cualquiera de ellos según el catalog.schema.table
de la query — la frontera de inquilino es un objeto del catálogo (igual que ya
concluyó storage-tenancy-approach.md §2 para el almacenamiento), no una conexión dedicada
por cliente. Es la misma idea que 3.1, confirmada en un producto que sirve esto a escala.
3.4 · El patrón de aislamiento en compute compartido — pool de adjuntos, no de conexiones
De la práctica establecida en SaaS multi-inquilino — tenant-aware connection pooling (patentado y documentado desde hace más de una década, p. ej. connection pooling per-tenant) y su advertencia más citada, sobre fuga de estado entre inquilinos en pools compartidos (PgBouncer transaction mode: "Anything you set on the session … outlives the transaction that set it and leaks into the next tenant's transaction on the same backend"):
El patrón correcto es un pool de adjuntos por inquilino (aquí: por warehouse), con
evicción LRU cuando se llega a un techo, y el estado del inquilino establecido por
operación, no por conexión reutilizada a ciegas.
Traducido a DuckDB: un mapa warehouseName → catálogo adjunto, con TTL/LRU. Un ATTACH
la primera vez que se ve ese warehouse; las siguientes queries de ese inquilino lo
reutilizan gratis; los adjuntos que no se usan se DETACH cuando el mapa llega a su
tamaño máximo.
4 · La traducción a nuestro paradigma
No hace falta inventar nada — la mitad difícil, otra vez, ya está construida.
| Pieza | Dónde vive | Estado |
|---|---|---|
Warehouse por organización, con su key-prefix | Lakekeeper, vía provisionTenantWarehouse | ✅ P1, hecho y verificado |
| Resolver qué warehouse le toca a un workspace | tenantWarehouseNameForDataset / tenant_warehouses | ✅ ya existe, lo usan ingest-client.ts y read-client.ts |
Identidad OIDC de duck-server, con acceso a cualquier warehouse | lakekeeper-duck-server (Keycloak, audience mapper puesto) | ✅ ya existe |
duck-client.ts pide el warehouse del tenant y lo manda | — | 🔜 la pieza que falta, lado Node |
duck-server mantiene un pool de catálogos adjuntos, LRU | services/duck-server/ | 🔜 la pieza que falta, lado servidor |
Node (duck-client.ts) duck-server (FastAPI)
│ │
│ workspaceId → tenantWarehouseName() │
│ (igual que ingest-client.ts / P4-bis) │
▼ ▼
POST /query { warehouse: "carbon-prod-w_…", sql, … }
│
¿"carbon-prod-w_…" ya adjunto?
│ sí │ no
▼ ▼
reutiliza el catálogo ATTACH (OAuth2 + /v1/config,
ya adjunto — gratis cachea el `prefix`) → añade
al mapa LRU
│ │
└───────────┬───────────┘
▼
ejecuta contra ese catálogo
(namespace `main.default`,
dentro del prefijo del tenant)
4.1 · Por qué el pool LRU, y no adjuntar todos al arrancar
Con 3 organizaciones es indiferente. Con cientos, adjuntar todo al boot:
- tarda proporcional al número de warehouses (cada uno es un
GET /v1/config); - dejaría fuera a cualquier organización nueva hasta el siguiente reinicio, que es
exactamente el mismo defecto de "no se entera de lo nuevo" que motivó el disparo
inmediato de
tenant-provisioning-worker.tspara el aprovisionamiento.
El adjunto perezoso (attach-on-demand) resuelve las dos cosas a la vez: escala con inquilinos activos, no con inquilinos totales, y una organización recién aprovisionada funciona en su primera query, sin desplegar nada.
4.2 · El techo del pool, y qué pasa al desbordarlo
Cada catálogo adjunto tiene coste en memoria/handles dentro de duck-server. Un techo
(p. ej. 200-500 adjuntos vivos, medible con una prueba de carga real antes de fijar el
número) con evicción LRU es el mismo patrón que un pool de conexiones JDBC por-inquilino —
la organización menos usada recientemente se DETACH, y su siguiente query paga un
ATTACH de nuevo. No hay pérdida de datos ni de aislamiento: solo latencia ocasional en
la organización menos activa.
5 · Lo que NO resuelve esto, y hay que decirlo — la credencial S3 sigue sin acotar por tenant
Aquí es donde este approach no puede replicar el nivel de aislamiento que sí tiene el
camino de ingest-client.ts/read-client.ts (P3-bis, llave acuñada por tabla vía la
puerta REST). Motivo, ya documentado en duckdbengine.md §7.2 — y no es nuestro bug,
está aguas arriba:
DuckDB no consume bien el credential vending de Lakekeeper hoy: con
ACCESS_DELEGATION_MODE=vended_credentialsla metadata carga pero leer los ficheros de
datos da403— reutiliza las credenciales acotadas almetadata_pathpara el
data_pathsin repedir. Bug activo, sin fix:
duckdb-iceberg#792 ·
duckdb-iceberg#670.
⇒ duck-server sigue con su llave S3 estática, acotada al bucket completo
(DUCK_S3_SCOPE=s3://lakehouse/), no al prefijo del tenant. La frontera de inquilino para
este camino vive enteramente en qué warehouse resuelve duck-client.ts y adjunta el
pool — si eso se calcula bien, Lakekeeper nunca devuelve ni acepta escrituras fuera del
key-prefix del warehouse adjunto (el catálogo, no la credencial S3, es quien construye
las rutas). Pero es defensa en una sola capa, no en dos como el camino acuñado.
Consecuencia práctica, para decidir con el resto del equipo: el gate de este approach
no puede ser solo "la query fue al warehouse correcto" — tiene que incluir el mismo
control negativo que ya se usó en P1-P3 (c5-face-gate, p3-born-in-tenant-gate.ts):
con el pool de duck-server sirviendo a la organización A, un intento de referenciar
explícitamente el warehouse de B en la misma sesión debe fallar — y hoy fallaría solo
si duck-client.ts nunca construye ni envía ese nombre, no porque Lakekeeper lo bloquee
por credencial. Es el mismo límite que P4 ya aceptó y dejó escrito para el resto del
camino de duck-server; aquí se hereda, no se introduce.
6 · Lo que SÍ falta para "leer y escribir plenamente", más allá del enrutado
El pedido original era lectura y escritura completas por tenant. El enrutado (§4) es
necesario pero no basta — dos huecos más, medidos hoy contra las variables reales de
duck-server en Railway:
- La llave S3 de
duck-serveres de solo lectura.LAKEHOUSE_S3_ACCESS_KEY_IDyLAKEHOUSE_S3_RO_ACCESS_KEY_IDson el mismo valor hoy — confirma lo questorage-tenancy-approach.md§4/P4 ya decía:duck-serverno tiene con qué escribir. Hace falta una credencial R2 de escritura (aunque siga acotada al bucket completo, no al prefijo — ver §5) antes de queallow_writesenduck-client.tstenga sentido más allá del403que hoy devuelve el guard. - El
write_targetgobernado (F4,duckdbengine.md§5) sigue detrás de un canario por-dataset (sql-editor.dml→iceberg_native). UnCREATE TABLEnuevo en una organización nueva no tiene filadatasetsprevia con ese flag — así que, aparte del enrutado de warehouse, hay que decidir el criterio de arranque del canario para tablas que nacen desde cero (¿heredan el flag de la organización? ¿nacen ya con él?). Es una decisión de producto, no solo de infraestructura, y no se responde en este documento.
7 · El plan, por fases — mismo criterio que P1-P4 en storage-tenancy-approach.md
F-A · Resolver y mandar el warehouse (lado Node, pequeño y aislado)
duck-client.tsañadewarehouse?: stringaDuckQueryOptsy lo pide contenantWarehouseNameForDataset/el equivalente porworkspaceId(hoy esa función resuelve pordatasetId; para el SQL Editor hace falta la variante porworkspaceIddirectamente — pequeño, mismo módulo).- Gate: una query de "test 1" manda
warehouse: "carbon-prod-w_cbe70f74…"en el body aduck-server. Verificable con un log/echo antes de tocar el servidor.
F-B · El pool de adjuntos en duck-server (lado Python, la pieza nueva real)
- Mapa
warehouse → catálogo adjunto,ATTACHperezoso, LRU con techo configurable. - Gate: dos organizaciones distintas, en la misma vida del proceso, cada una lee/escribe
en su propio prefijo — medido con
metadata-location, igual que hizop3-born-in-tenant-gate.tspara el otro camino.
F-C · Escritura real (la llave + el canario, §6)
- Credencial R2 de escritura para
duck-server(acotada a bucket, defensa de capa única — documentado como aceptado, no como resuelto). - Decisión de producto sobre el canario
sql-editor.dmlpara tablas nuevas.
F-D · El control negativo (el gate que de verdad cierra esto)
- Con el pool sirviendo A, intentar leer/escribir B desde la misma sesión debe fallar.
- Documentar el resultado igual que P3-bis: si falla por ausencia de referencia (porque
duck-client.tsnunca la construye) en vez de por credencial, se anota como el mismo límite de una sola capa que ya aceptó P4 — no se vende como más de lo que es.
8 · Lo que este approach NO cubre
- Vending de credenciales S3 por tenant para DuckDB — bloqueado aguas arriba
(duckdb-iceberg#792/#670). Re-evaluar en cada release de DuckDB, igual que ya hace
duckdbengine.md§7.2. - El tamaño del techo del pool LRU — necesita una prueba de carga real contra
duck-server, no un número elegido a mano. - El criterio del canario
sql-editor.dmlpara datasets nuevos — decisión de producto, fuera del alcance de infraestructura de este documento.