diff --git a/docs/content/product/apis-integrations/core-data-apis/dax-api/_meta.js b/docs/content/product/apis-integrations/core-data-apis/dax-api/_meta.js index c37c3be470df6..7b63b67855604 100644 --- a/docs/content/product/apis-integrations/core-data-apis/dax-api/_meta.js +++ b/docs/content/product/apis-integrations/core-data-apis/dax-api/_meta.js @@ -1,3 +1,4 @@ export default { + "cross-view-filter": "Cross-view filtering", "reference": "Reference" } \ No newline at end of file diff --git a/docs/content/product/apis-integrations/core-data-apis/dax-api/cross-view-filter.mdx b/docs/content/product/apis-integrations/core-data-apis/dax-api/cross-view-filter.mdx new file mode 100644 index 0000000000000..bb32d4398b0ab --- /dev/null +++ b/docs/content/product/apis-integrations/core-data-apis/dax-api/cross-view-filter.mdx @@ -0,0 +1,85 @@ +# Cross-view filtering in the DAX API + +By default, the [DAX API][ref-dax-api] exposes each [view][ref-views] as a +separate perspective in [Power BI][ref-powerbi]. Visualizations from different +views can't share the same filters, and a single report can't combine measures +and dimensions across views. + +Cross-view filtering removes this limitation, letting you build dashboards +that span multiple views and apply filters consistently across visualizations. +It is supported in both [Live connection](#live-connection-mode) and +[DirectQuery](#directquery-mode) modes. + +To use cross-view filters such as slicers, you must enable single-perspective mode +in the DAX API. To enable single-perspective mode in the DAX API, +set the CUBEJS_DAX_SINGLE_PERSPECTIVE environment variable to `true`. + +In single-perspective mode, all views are exposed as part of a single perspective. +This allows you to use a single connection and show visualizations from different +views on the same dashboard. + + + +Single-perspective mode changes the way views are exposed by the DAX API. +Because of that, it is not backwards-compatible with dashboards created before +enabling this environment variable. + + + +## Live connection mode + +In Live connection mode, cross-view filters work automatically, provided that +the following two conditions are met: + +- The column used in the filter must be present in every view that the +cross-filter has to be applied to, and named exactly the same in each view. +- The column in all such views must resolve to the same cube member. + +For example, if views `orders_view` and `users_view` both expose a column named +`address_country` pointing to the `country.country` cube member, cross-filtering +will be applied across these views. + +If the `address_country` column in `orders_view` points to `orders.country` +while the `address_country` column in `users_view` points to `users.country`, +cross-filtering will not be applied, because the columns resolve to different +cube members. + +Likewise, if `orders_view` exposes an `address_country` column pointing to +`country.country` and `users_view` exposes a `country` column also pointing to +`country.country`, cross-filtering will not be applied, because the column +names differ between the views. + +## DirectQuery mode + +In DirectQuery mode, cross-view filters require the same two conditions as in +[Live connection mode](#live-connection-mode): the filter column must be +present in every view with the exact same name, and it must resolve to the +same cube member across those views. + +In addition, you must manually configure relationships between tables in Power +BI so that filters propagate across views: + +- Open Model view in the left sidebar. +- On the right, open the Data sidebar, then the Model tab. +- Right-click Relationships and choose New relationship. +- In the Properties sidebar, select a table and a column. +- For cardinality, choose Many to many (*:*). Ignore the warning. +- Select another table with the same column. +- For cross-filter direction, select Both. +- Click Apply changes. + +Once the relationship is configured correctly, cross-view filters will work in +DirectQuery mode. + +The steps above describe configuring a relationship between two tables. When +many views need to be cross-filtered, the best practice is to create a single +view in the Cube data model that includes all the columns from the views that +need to be cross-filtered, and then create relationships between this large +view and each of the other views. This keeps the relationship graph in Power +BI simple and avoids configuring pairwise relationships between every +combination of views. + + +[ref-dax-api]: /product/apis-integrations/dax-api +[ref-views]: /product/data-modeling/concepts#views +[ref-powerbi]: /product/configuration/visualization-tools/powerbi diff --git a/docs/content/product/apis-integrations/core-data-apis/dax-api/index.mdx b/docs/content/product/apis-integrations/core-data-apis/dax-api/index.mdx index 4a965ac625bec..366bd1fe94ff1 100644 --- a/docs/content/product/apis-integrations/core-data-apis/dax-api/index.mdx +++ b/docs/content/product/apis-integrations/core-data-apis/dax-api/index.mdx @@ -68,8 +68,20 @@ The DAX API only exposes [views][ref-views], not cubes. +## Cross-view filtering + +By default, each view is exposed as a separate perspective in Power BI, so +visualizations from different views can't share the same filters. Cross-view +filtering lets you build dashboards that span multiple views and apply filters +consistently across visualizations, in both Live connection and DirectQuery +modes. + +See [Cross-view filtering][ref-cross-view-filter] for details on how to enable +and use it. + [ref-powerbi]: /product/configuration/visualization-tools/powerbi +[ref-cross-view-filter]: /product/apis-integrations/dax-api/cross-view-filter [link-dax]: https://learn.microsoft.com/en-us/dax/ [ref-sql-api]: /product/apis-integrations/sql-api [ref-ref-dax-api]: /product/apis-integrations/dax-api/reference diff --git a/packages/cubejs-athena-driver/src/AthenaDriver.ts b/packages/cubejs-athena-driver/src/AthenaDriver.ts index cc3ad6f00bdca..b89dd5302688f 100644 --- a/packages/cubejs-athena-driver/src/AthenaDriver.ts +++ b/packages/cubejs-athena-driver/src/AthenaDriver.ts @@ -553,11 +553,25 @@ export class AthenaDriver extends BaseDriver implements DriverInterface { Math.min(this.config.pollMaxInterval, 500 * i) ); } + await this.stopQuery(qid); throw new Error( `Athena job timeout reached ${this.config.pollTimeout}ms` ); } + // Best-effort: a failure to stop must never bubble up to the caller, + // which has already abandoned the query. + protected async stopQuery(qid: AthenaQueryId): Promise { + try { + await this.athena.stopQueryExecution({ QueryExecutionId: qid.QueryExecutionId }); + } catch (e) { + this.logger?.('Failed to stop Athena query', { + queryExecutionId: qid.QueryExecutionId, + error: (e as Error).message ?? String(e), + }); + } + } + protected async viewsSchema(tablesSchema: DatabaseStructure): Promise { const isView = (table: AthenaTable) => !tablesSchema[table.schema] || !tablesSchema[table.schema][table.name]; diff --git a/packages/cubejs-athena-driver/test/AthenaDriver.test.ts b/packages/cubejs-athena-driver/test/AthenaDriver.test.ts index 11c3f22532231..1ac567dc5ac02 100644 --- a/packages/cubejs-athena-driver/test/AthenaDriver.test.ts +++ b/packages/cubejs-athena-driver/test/AthenaDriver.test.ts @@ -1,5 +1,7 @@ // eslint-disable-next-line import/no-extraneous-dependencies import { DriverTests, smartStringTrim } from '@cubejs-backend/testing-shared'; +import { pausePromise } from '@cubejs-backend/shared'; +import { QueryExecutionState } from '@aws-sdk/client-athena'; import { AthenaDriver } from '../src'; @@ -15,14 +17,32 @@ class AthenaDriverTest extends DriverTests { } } +// A row-by-row regex+concat over a 49999×49999 cross-join forces full +// materialization (no aggregate pushdown), so Athena cannot finish +// before the driver's pollTimeout fires. +const SLOW_QUERY = ` + SELECT count(*) AS c + FROM ( + SELECT length(regexp_replace( + CONCAT(CAST(a.i * b.j + 7919 AS VARCHAR), '-', CAST(a.i AS VARCHAR)), + '[0-9]', 'd' + )) AS n + FROM unnest(sequence(1, 49999)) AS a(i) + CROSS JOIN unnest(sequence(1, 49999)) AS b(j) + ) + WHERE n > 0 +`; + describe('AthenaDriver', () => { let tests: AthenaDriverTest; + let driver: AthenaDriver; - jest.setTimeout(2 * 60 * 1000); + jest.setTimeout(3 * 60 * 1000); beforeAll(async () => { + driver = new AthenaDriver({}); tests = new AthenaDriverTest( - new AthenaDriver({}), + driver, { expectStringFields: true, csvNoHeader: true, @@ -53,4 +73,41 @@ describe('AthenaDriver', () => { test('unload empty', async () => { await tests.testUnloadEmpty(); }); + + test('pollTimeout cancels the in-flight Athena query', async () => { + // Aggressive pollTimeout (5s) so the test doesn't depend on the + // ambient CUBEJS_DB_QUERY_TIMEOUT. Constructor multiplies by 1000. + const cancelDriver = new AthenaDriver({ pollTimeout: 5 }); + const athena = (cancelDriver as any).athena; + + const startOriginal = athena.startQueryExecution.bind(athena); + let queryExecutionId = ''; + athena.startQueryExecution = async (input: any) => { + const result = await startOriginal(input); + queryExecutionId = result.QueryExecutionId; + return result; + }; + + try { + await expect(cancelDriver.query(SLOW_QUERY, [])).rejects.toThrow(/Athena job timeout/); + expect(queryExecutionId).toBeTruthy(); + + // Verify Athena's own view of the query: must be CANCELLED (or + // FAILED, if a cancel raced with completion) — never SUCCEEDED. + for (let i = 0; i < 30; i++) { + const exec = await athena.getQueryExecution({ QueryExecutionId: queryExecutionId }); + const state = exec.QueryExecution?.Status?.State; + if (state === QueryExecutionState.CANCELLED || state === QueryExecutionState.FAILED) { + return; + } + if (state === QueryExecutionState.SUCCEEDED) { + throw new Error(`Athena query ${queryExecutionId} succeeded before cancel took effect`); + } + await pausePromise(500); + } + throw new Error(`Athena query ${queryExecutionId} did not reach a terminal state within 15s of cancel`); + } finally { + await cancelDriver.release(); + } + }); });