diff --git a/docs-mintlify/recipes/configuration/multiple-sources-same-schema.mdx b/docs-mintlify/recipes/configuration/multiple-sources-same-schema.mdx
index c789b2d60b79c..0b76372919c6a 100644
--- a/docs-mintlify/recipes/configuration/multiple-sources-same-schema.mdx
+++ b/docs-mintlify/recipes/configuration/multiple-sources-same-schema.mdx
@@ -17,19 +17,39 @@ the local database (bootstrapped in the `docker-compose.yml` file) which has the
same data model.
To enable multitenancy, use the
-[`contextToAppId`](/reference/configuration/config#context_to_app_id) function to
-provide distinct identifiers for each tenant. Also, implement the
+[`contextToAppId`](/reference/configuration/config#context_to_app_id) and
+[`contextToOrchestratorId`](/reference/configuration/config#context_to_orchestrator_id)
+functions to provide distinct identifiers for each tenant. Also, implement the
[`driverFactory`](/reference/configuration/config#driver_factory) function where
you can select a data source based on the tenant name.
[JSON Web Token](/docs/data-modeling/access-control) includes information about the tenant name in
the `tenant` property of the `securityContext`.
+
+
+Whenever `driverFactory` picks a connection based on the `securityContext`, you
+must also define `contextToOrchestratorId` and derive it from the same tenant
+identifier. `driverFactory` is called
+[once per data source for every orchestrator id](/reference/configuration/config#driver_factory),
+and the orchestrator id defaults to a single global value for all tenants — so
+without it, every tenant reuses the database connection resolved for whichever
+tenant queried first, and one tenant receives another tenant's data.
+
+
+
```javascript
module.exports = {
// Provides distinct identifiers for each tenant which are used as caching keys
+ // for the data model compilation results
contextToAppId: ({ securityContext }) =>
`CUBE_APP_${securityContext.tenant}`,
+ // Caching key for database connections, execution queues and pre-aggregation
+ // table caches. Must be tenant-specific because driverFactory below selects a
+ // connection based on the security context
+ contextToOrchestratorId: ({ securityContext }) =>
+ `CUBE_APP_${securityContext.tenant}`,
+
// Selects the database connection configuration based on the tenant name
driverFactory: ({ securityContext }) => {
if (!securityContext.tenant) {
diff --git a/docs-mintlify/reference/configuration/config.mdx b/docs-mintlify/reference/configuration/config.mdx
index 26b1a7fa2fd2e..7ad426651ea76 100644
--- a/docs-mintlify/reference/configuration/config.mdx
+++ b/docs-mintlify/reference/configuration/config.mdx
@@ -55,6 +55,11 @@ It's a [multitenancy][ref-multitenancy] option.
caching key for various in-memory structures like data model compilation
results, etc.
+It does not key database connections, execution queues, or pre-aggregation table
+caches — those use [`context_to_orchestrator_id`][self-orchestrator-id], which
+you must also define if [`driver_factory`][self-driver-factory] selects a
+connection based on the security context.
+
Called on each request.
@@ -321,6 +326,15 @@ execution queues, pre-aggregation table caches. By default, the same instance is
used for **all** tenants; override this property in situations where each tenant
requires their own Query Orchestrator.
+Overriding it is **required** whenever [`driver_factory`][self-driver-factory]
+selects a connection based on the security context, and the id must be derived
+from the same tenant identifier. Setting
+[`context_to_app_id`][self-opts-ctx-to-appid] alone is not enough: the app id keys
+the data model compilation cache, while the driver is resolved once per data
+source per *orchestrator* id. With the default single global orchestrator id,
+every tenant reuses the connection resolved for whichever tenant queried first,
+so one tenant receives another tenant's data.
+
Please remember to override
@@ -1511,6 +1525,7 @@ module.exports = {
[ref-rest-scopes]: /reference/core-data-apis/rest-api#api-scopes
[ref-config-options]: /admin/connect-to-data#configuration-options
[self-orchestrator-id]: #context_to_orchestrator_id
+[self-driver-factory]: #driver_factory
[ref-multiple-data-sources]: /admin/connect-to-data/multiple-data-sources
[ref-websockets]: /recipes/core-data-api/real-time-data-fetch
[ref-matching-preaggs]: /docs/pre-aggregations/matching-pre-aggregations
diff --git a/examples/recipes/multiple-data-sources/cube.js b/examples/recipes/multiple-data-sources/cube.js
index dd87548280244..fadc264c22731 100644
--- a/examples/recipes/multiple-data-sources/cube.js
+++ b/examples/recipes/multiple-data-sources/cube.js
@@ -2,9 +2,17 @@ const PostgresDriver = require('@cubejs-backend/postgres-driver');
module.exports = {
// Provides distinct identifiers for each tenant which are used as caching keys
+ // for the data model compilation results
contextToAppId: ({ securityContext }) =>
`CUBEJS_APP_${securityContext.tenant}`,
+ // Caching key for database connections, execution queues and pre-aggregation
+ // table caches. It must be tenant-specific whenever driverFactory selects a
+ // connection based on the security context, otherwise every tenant shares the
+ // driver resolved for whichever tenant queried first.
+ contextToOrchestratorId: ({ securityContext }) =>
+ `CUBEJS_APP_${securityContext.tenant}`,
+
// Selects the database connection configuration based on the tenant name
driverFactory: ({ securityContext }) => {
diff --git a/packages/cubejs-server-core/src/core/OptsHandler.ts b/packages/cubejs-server-core/src/core/OptsHandler.ts
index 8021bb08bf7b7..66f7edfa0c7e0 100644
--- a/packages/cubejs-server-core/src/core/OptsHandler.ts
+++ b/packages/cubejs-server-core/src/core/OptsHandler.ts
@@ -455,6 +455,20 @@ export class OptsHandler {
});
}
+ if (
+ opts.contextToAppId &&
+ !opts.contextToOrchestratorId &&
+ this.isCustomDriverFactory()
+ ) {
+ this.core.logger('Multitenancy Without ContextToOrchestratorId', {
+ warning: (
+ 'You are using multitenancy with a custom driverFactory but without ' +
+ 'configuring contextToOrchestratorId: ' +
+ 'https://cube.dev/docs/reference/configuration/config#context_to_orchestrator_id'
+ ),
+ });
+ }
+
if (options.devServer && !options.apiSecret) {
options.apiSecret = crypto.randomBytes(16).toString('hex');
displayCLIWarning(
diff --git a/packages/cubejs-server-core/test/unit/OptsHandler.test.ts b/packages/cubejs-server-core/test/unit/OptsHandler.test.ts
index db97854bbe5a5..de61dba2da29e 100644
--- a/packages/cubejs-server-core/test/unit/OptsHandler.test.ts
+++ b/packages/cubejs-server-core/test/unit/OptsHandler.test.ts
@@ -1202,4 +1202,62 @@ describe('OptsHandler class', () => {
expect(Array.isArray(permissions)).toBeTruthy();
expect(permissions).toEqual(['graphql', 'meta', 'data', 'jobs']);
});
+
+ describe('contextToOrchestratorId warning', () => {
+ const WARNING_MSG = 'Multitenancy Without ContextToOrchestratorId';
+
+ const tenantId = ({ securityContext }: any) => (
+ `CUBE_APP_${securityContext.tenant}`
+ );
+
+ const buildCore = (opts: CreateOptions) => {
+ const logger = jest.fn();
+ const core = new CubejsServerCoreExposed({
+ ...conf,
+ logger,
+ scheduledRefreshTimer: false,
+ ...opts,
+ });
+
+ expect(core.options).toBeDefined();
+
+ return logger.mock.calls.filter((call) => call[0] === WARNING_MSG);
+ };
+
+ test('must warn on tenant-specific driverFactory without contextToOrchestratorId', () => {
+ const warnings = buildCore({
+ contextToAppId: tenantId,
+ driverFactory: ({ securityContext }: any) => ({
+ type: 'postgres',
+ database: `tenant_${securityContext.tenant}`,
+ }),
+ });
+
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0][1].warning).toContain('contextToOrchestratorId');
+ });
+
+ test('must not warn when contextToOrchestratorId is configured', () => {
+ const warnings = buildCore({
+ contextToAppId: tenantId,
+ contextToOrchestratorId: tenantId,
+ driverFactory: ({ securityContext }: any) => ({
+ type: 'postgres',
+ database: `tenant_${securityContext.tenant}`,
+ }),
+ });
+
+ expect(warnings).toHaveLength(0);
+ });
+
+ test('must not warn without a custom driverFactory', () => {
+ process.env.CUBEJS_DB_TYPE = 'postgres';
+
+ const warnings = buildCore({
+ contextToAppId: tenantId,
+ });
+
+ expect(warnings).toHaveLength(0);
+ });
+ });
});