Published

DuckDB detrás de Junction, sirviendo N warehouses — el enrutado que falta

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

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 su key-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-server sigue 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:

ClienteResuelve tenantWarehouseNameForDatasetManda 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 ATTACH en sí (intercambio OAuth2 + GET /v1/config para resolver el prefix del warehouse — la misma llamada que hace upstreamPrefix() 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.

PiezaDónde viveEstado
Warehouse por organización, con su key-prefixLakekeeper, vía provisionTenantWarehouse✅ P1, hecho y verificado
Resolver qué warehouse le toca a un workspacetenantWarehouseNameForDataset / tenant_warehouses✅ ya existe, lo usan ingest-client.ts y read-client.ts
Identidad OIDC de duck-server, con acceso a cualquier warehouselakekeeper-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, LRUservices/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.ts para 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_credentials la metadata carga pero leer los ficheros de
datos da 403 — reutiliza las credenciales acotadas al metadata_path para el
data_path sin 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:

  1. La llave S3 de duck-server es de solo lectura. LAKEHOUSE_S3_ACCESS_KEY_ID y LAKEHOUSE_S3_RO_ACCESS_KEY_ID son el mismo valor hoy — confirma lo que storage-tenancy-approach.md §4/P4 ya decía: duck-server no tiene con qué escribir. Hace falta una credencial R2 de escritura (aunque siga acotada al bucket completo, no al prefijo — ver §5) antes de que allow_writes en duck-client.ts tenga sentido más allá del 403 que hoy devuelve el guard.
  2. El write_target gobernado (F4, duckdbengine.md §5) sigue detrás de un canario por-dataset (sql-editor.dmliceberg_native). Un CREATE TABLE nuevo en una organización nueva no tiene fila datasets previa 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.ts añade warehouse?: string a DuckQueryOpts y lo pide con tenantWarehouseNameForDataset/el equivalente por workspaceId (hoy esa función resuelve por datasetId; para el SQL Editor hace falta la variante por workspaceId directamente — pequeño, mismo módulo).
  • Gate: una query de "test 1" manda warehouse: "carbon-prod-w_cbe70f74…" en el body a duck-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, ATTACH perezoso, 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 hizo p3-born-in-tenant-gate.ts para 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.dml para 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.ts nunca 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.dml para datasets nuevos — decisión de producto, fuera del alcance de infraestructura de este documento.