diff --git a/docs-mintlify/docs/explore-analyze/charts/chart-types/table.mdx b/docs-mintlify/docs/explore-analyze/charts/chart-types/table.mdx
index b230d42f72f90..681d3431ab582 100644
--- a/docs-mintlify/docs/explore-analyze/charts/chart-types/table.mdx
+++ b/docs-mintlify/docs/explore-analyze/charts/chart-types/table.mdx
@@ -49,6 +49,8 @@ with the header showing the dimension's value once instead of on every row under
Turning it on also adds a **Row grouping** section to the **Style** tab, with:
- **Expand all groups** / **Collapse all groups** — set every group's expand state at once.
+- **Subtotals** (Σ) — show an aggregated value for every measure on each group header row.
+ See [Per-group subtotals](#per-group-subtotals).
- Text color, background color, and text formatting for the group header rows.
Each group-level column's card in the **Columns** section gets a **Default state**
@@ -58,6 +60,31 @@ individual group isn't affected by changing it afterwards.
{/* TODO screenshot: table with collapsed row groups and the Style tab's Row grouping section */}
+### Per-group subtotals
+
+With grouping on, the **Subtotals** (Σ) toggle in the **Row grouping** section adds an
+aggregated value for every measure to each group header row, at every nesting level. Not
+to be confused with [pivot subtotals](#subtotals), which add a **Total for ‹value›**
+column per pivot group rather than a value per group header row.
+
+Each subtotal is queried at its own group's grain rather than summed from the rows below
+it. That distinction matters for any measure that doesn't add up: an `avg` shows the true
+average over the group instead of an average of averages, and a `count_distinct` counts a
+value once even when it appears in several child groups. As a result a subtotal can
+legitimately differ from the sum of the rows beneath it — for a distinct count it is
+usually smaller.
+
+It also means the value stays correct on a collapsed group, where there are no visible
+rows to add up at all.
+
+Measures built on window functions (`RUNNING_TOTAL`, `OFFSET`, or a SQL `OVER` clause)
+can't be recomputed at another grain, so their cells stay empty on group rows — the same
+exclusion that [row, column, and pivot
+totals](/docs/explore-analyze/workbooks/querying-data#subtotals) make. Period-comparison
+columns are also left empty. The toggle is disabled unless grouping is active and the
+values are placed in columns, since the values-as-rows layout has no per-measure column
+for a subtotal to sit under.
+
## Column field options
Configure individual columns in the **Columns** section of the **Style** tab (or via the dropdown arrow on a field in the **Fields** section):
@@ -486,7 +513,7 @@ For the computed anchors (Minimum, Maximum, Midpoint, Average, Median), the drop
Use **Reverse color scale** to swap the Start and End colors. **Treat nulls as zero** is on by default, coloring null/blank cells as zero; turn it off to leave them uncolored.
-The scale is normalized **per column** — each targeted column uses its own value range, taken from the column's data cells only. With [totals](#totals) enabled, the row totals and subtotal columns are still colored by the scale of the measure they aggregate, but they don't widen its range: a total past the column's highest stop is clamped to that stop's color. Cells in the column totals row are never colored by a scale, since totals rows don't take formatting rules.
+The scale is normalized **per column** — each targeted column uses its own value range, taken from the column's data cells only. With [totals](#totals) enabled, the row totals and [pivot subtotal](#subtotals) columns are still colored by the scale of the measure they aggregate, but they don't widen its range: a total past the column's highest stop is clamped to that stop's color. Cells in the column totals row are never colored by a scale, since totals rows don't take formatting rules.
{/* TODO screenshot: table with a color scale applied to a numeric column (hidden — replace this comment with
when image is ready) */}
@@ -496,4 +523,4 @@ Enable column totals and row totals from the **Totals** section of the **Style**
### Subtotals
-When the table is pivoted by two or more dimensions, you can also enable **subtotals** — a bold **Total for ‹value›** column appended after each pivot group's columns, at every nesting level. Subtotals combine freely with column and row totals, work in workbooks and on dashboards, and — like the other totals — carry over from the results table when you switch the chart type to Table. See [Subtotals](/docs/explore-analyze/workbooks/querying-data#subtotals) for details and limitations.
+When the table is pivoted by two or more dimensions, you can also enable **subtotals** — a bold **Total for ‹value›** column appended after each pivot group's columns, at every nesting level. Not to be confused with [per-group subtotals](#per-group-subtotals), which add a value to each row group's header row rather than a column per pivot group. Subtotals combine freely with column and row totals, work in workbooks and on dashboards, and — like the other totals — carry over from the results table when you switch the chart type to Table. See [Subtotals](/docs/explore-analyze/workbooks/querying-data#subtotals) for details and limitations.
diff --git a/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx b/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx
index 24c6e0f6eb3bf..f308980e3b940 100644
--- a/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx
+++ b/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx
@@ -36,7 +36,7 @@ You can set a default value that's applied when the dashboard loads. Defaults ar
There are two ways to set a default:
- **Static default** — pick a value (or values) directly in the filter. Every viewer sees the same default.
-- **User attribute default** — resolve the default from the viewer's [user attribute][ref-user-attributes] at load time, so each viewer sees their own personalized default.
+- **User attribute default** — resolve the default from the viewer's [user attribute][ref-user-attributes] at load time, so each viewer sees their own personalized default. [Parent controls](#default-option) support this too.
Static defaults are configured by interacting with the filter in the dashboard builder — the value you select is saved on the widget and applied to every viewer when the dashboard loads.
@@ -147,9 +147,91 @@ Click the indicator to jump straight to the **Children** tab of the parent that
### Default option
-Set **Default value** to the option that should be selected when the dashboard loads. Choosing a default in the builder also applies that option's values to the children, so their saved defaults line up with the parent's and a published dashboard opens in a consistent state.
+The option a parent control opens on is the one you last picked **in the control itself on the dashboard**, not a field in its settings — the same way a filter's static default and a time granularity switcher's default are set. Pick an option in the dashboard builder and it's saved on the widget and applied to every viewer when the dashboard loads.
-If you leave **Default value** empty, the parent opens with nothing selected and the children use their own defaults.
+Picking in the builder also applies that option's values to the children, so their saved defaults line up with the parent's and a published dashboard opens in a consistent state.
+
+If you never pick an option, the parent opens with nothing selected and the children use their own defaults. Deleting the option that was serving as the default clears it, and the parent goes back to opening on nothing.
+
+#### User attribute default
+
+The default above is one arrangement for everyone. To give each viewer their own, turn on **User attribute default** in the parent control's settings and pick a [user attribute][ref-user-attributes]. When the dashboard loads, Cube reads that attribute for the current viewer and opens the control on the option it names — and drives the children with it, exactly as if the viewer had picked that option themselves.
+
+This is how you ship one dashboard that opens differently per audience: a **Reporting period** parent whose options are `Month` and `Quarter`, opening on whichever one the viewer's `reporting_period` attribute says, with every filter and time granularity switcher behind it already set to match.
+
+To configure it:
+
+
+
+ In the dashboard builder, click **Configure Parent** on the control, or open its settings menu.
+
+
+ Scroll to the **User attribute default** switch and turn it on.
+
+
+ Select the [user attribute][ref-user-attributes] to resolve. Only attributes defined in your account appear in the picker.
+
+
+
+The attribute value is matched against the **option labels**, ignoring case and surrounding spaces — an attribute reading `quarter` selects the option labelled `Quarter`. Give the options the labels your attribute already uses, or adjust the attribute values to match; renaming an option later doesn't disturb the child mappings, so labels are safe to align after the fact.
+
+| Attribute type | How it's applied |
+|---|---|
+| **String**, **Number** | Matched against the option labels as a single value. |
+| **String array**, **Number array** | The first entry that names an option wins. A parent control is single-select, so the rest are ignored. |
+
+If the value matches no option — or is empty, `null`, or unresolvable — the control falls back to the [default option](#default-option) you picked, and the children keep the arrangement that goes with it.
+
+
+The attribute is resolved for the viewer, not baked into the dashboard. Editing the attribute's value changes what that viewer opens on the next time the dashboard loads; it never rewrites the published dashboard, so the default you picked in the builder stays intact for everyone else.
+
+
+Viewers can still switch to another option unless the control's [visibility](#visibility) is set to **Disabled**, and their own pick outranks the attribute for the rest of the session. Values passed [in the URL](#sharing-the-current-selection) outrank both, so a shared link opens on the values it names rather than on the recipient's attribute.
+
+## Sharing the current selection
+
+On a published dashboard, the values a viewer picks in the controls are reflected
+in the URL, so the view they are looking at is bookmarkable and shareable. Copy
+the address bar, send it on, and the recipient opens the dashboard with the same
+filters and granularities applied.
+
+Each control type has its own parameter:
+
+| Control | Parameter | Example |
+|---|---|---|
+| [Filter](#filter) | `f_.=` | `f_orders.status={"value":"shipped"}` |
+| [Time granularity switcher](#time-granularity-switcher) | `tg_.=` | `tg_orders.created_at=week` |
+
+The semantic view and dimension are the **internal names** configured on the
+control — not the display titles you see in the picker. A view shown as `Orders`
+is usually `orders` in the parameter. Granularities are lowercase: `day`, `week`,
+`month`, `quarter`, `year`.
+
+You can also write these parameters by hand to open a dashboard in a particular
+state — see [Pre-set dashboard filters via URL][ref-embed-url-filters] for the
+embedded case, which uses the same format.
+
+What does and doesn't travel in the link:
+
+- **Only what the viewer chose.** Values that came from the control's own
+ configuration — a static default, a [default granularity](#default-granularity)
+ — are not written into the URL. Every viewer already gets those from the
+ dashboard itself, and leaving them out means a link stays correct after the
+ dashboard's defaults change.
+- **Never a personalized default.** A value resolved from a
+ [user attribute](#user-attribute-default) stays out of the link — whether it
+ seeded a filter directly or reached one through a [parent control](#parent)
+ opening on the viewer's own option. Sharing a dashboard never pins your
+ attribute value onto the recipient; they see it through their own attributes.
+- **Filters and granularities together.** Picking both puts both in the link,
+ including when a [parent control](#parent) sets several children at once.
+- **Published dashboards only.** In the dashboard builder the URL is left to
+ the editing session, so editing a control there doesn't rewrite it.
+
+When a dashboard opens with these parameters, they are applied on top of whatever
+defaults the controls carry. A parameter is ignored when nothing on the dashboard
+can honor it — there is no matching control for that dimension, or the requested
+granularity isn't in the switcher's [allowed granularities](#allowed-granularities).
## Visibility
@@ -204,6 +286,7 @@ For [time granularity switchers][ref-time-grain], the dimension picker is restri
Mappings are also configurable by AI agents when they build or edit a dashboard, so an agent can wire controls across charts that use different semantic views without you needing to revisit each chart manually.
[ref-time-grain]: #time-granularity-switcher
+[ref-embed-url-filters]: /embedding/iframe/dashboards#pre-set-dashboard-filters-via-url
[ref-workbooks]: /docs/explore-analyze/workbooks
[ref-charts]: /docs/explore-analyze/dashboards/widgets/charts
diff --git a/docs-mintlify/docs/explore-analyze/notifications.mdx b/docs-mintlify/docs/explore-analyze/notifications.mdx
index 26b2c6b6115ed..56e2c3f8e0922 100644
--- a/docs-mintlify/docs/explore-analyze/notifications.mdx
+++ b/docs-mintlify/docs/explore-analyze/notifications.mdx
@@ -31,12 +31,11 @@ scratch — the copy carries over the source schedule's notification settings.
### Delivery channel
-Toggle between two delivery channels:
+A schedule delivers to one channel, not both. Pick it with the **Send via**
+radio buttons:
-- **Email** — select recipients using a searchable picker with checkboxes. The
- picker is grouped into **Users** and **User groups** sections, so you can add
- individual workspace users, entire [user groups][ref-user-groups], or a mix of
- both.
+- **Email** — sends to the [recipients](#recipients) you choose: individual
+ workspace users, [user groups][ref-user-groups], or a mix of both.
- **Slack** — select a Slack channel to post to. Requires connecting your Slack
workspace first (one-time OAuth flow via **Connect to Slack**). Once
connected, select a channel from a searchable picker.
@@ -75,17 +74,43 @@ from the published dashboard.
### Recipients
-The recipient picker is split into two sections:
+Under the **Recipients** heading there are two separate controls:
-- **Users** — individual workspace users.
-- **User groups** — [user groups][ref-user-groups]. A group is expanded to its
- current members each time the notification is sent, so adding or removing
- members takes effect on the next run without editing the schedule. The **User
- groups** section only appears when your workspace has at least one user group.
+- **Users** — a searchable picker for individual workspace users.
+- **User groups** — a dropdown holding an expandable checklist of
+ [user groups][ref-user-groups] and their members. It appears only when your
+ workspace has at least one user group.
+
+A group is expanded to its current members each time the notification is sent,
+so adding or removing members takes effect on the next run without editing the
+schedule. The number beside a group's name is how many members it can currently
+email; a member with no email address is not listed and is never counted.
A recipient who is selected individually and also belongs to a selected group is
emailed only once.
+#### Excluding individual group members
+
+Tick a group to notify everyone in it, then expand it and untick anyone who
+should be skipped. Those people are stored as exceptions to that group on this
+schedule — the group stays selected and keeps picking up new members, but the
+excluded members are left out of every run.
+
+One search box filters both levels at once, so typing a person's name narrows
+the list to the groups that would notify them, with that person shown under
+each.
+
+Exceptions are saved with the rest of the form, not applied as you click. A few
+details worth knowing:
+
+- Unticking a group's **last** remaining member unticks the whole group.
+- Exceptions belong to one schedule and one group. Excluding someone from a
+ group here does not affect the group anywhere else, or any other schedule.
+- An exception is not a block on the person. If they are **also** selected under
+ **Users**, or have [subscribed themselves][ref-subscribe], they still get the
+ email — a direct recipient row and group membership are independent, and
+ either one alone delivers.
+
## Slack notifications
Slack notifications post a message with the dashboard screenshot to a single
diff --git a/docs-mintlify/docs/explore-analyze/scheduled-refreshes.mdx b/docs-mintlify/docs/explore-analyze/scheduled-refreshes.mdx
index 0218ea7d83777..2687f878c6ecd 100644
--- a/docs-mintlify/docs/explore-analyze/scheduled-refreshes.mdx
+++ b/docs-mintlify/docs/explore-analyze/scheduled-refreshes.mdx
@@ -48,7 +48,7 @@ The dashboard must be published before schedules can be created.
Click **New scheduled refresh** in the sidebar footer. A dialog opens
with the following configuration options.
-### Frequency
+### Schedule {#frequency}
| Option | Description |
| --- | --- |
@@ -122,20 +122,37 @@ If a schedule sends [email notifications][ref-notifications], anyone who can vie
the dashboard can subscribe to it themselves — an editor does not have to add
them as a recipient. Open the scheduled refreshes sidebar from the published
dashboard and use the toggle on a schedule to subscribe or unsubscribe.
-Subscribing adds you to the schedule's email recipients; unsubscribing removes
-you. The change takes effect on the next run.
+
+The toggle covers **every** way that schedule could reach you, in one click. If
+you were added individually, unsubscribing removes you from its recipients. If
+you are reached through a [user group][ref-user-groups] — or several — it also
+records you as an exception to each of those groups on this schedule, so the
+group keeps notifying everyone else but stops emailing you. Subscribing reverses
+both: it clears those exceptions and adds you back as a recipient in your own
+right. The change takes effect on the next run.
+
+
+
+Unsubscribing affects only this schedule. It does not remove you from the user
+group, and it does not change any other schedule that group receives.
+
+
The toggle appears only for schedules that email individual recipients — those
with notifications enabled and the email delivery channel. A schedule delivers to
either individual inboxes or a Slack channel, not both, so a schedule that posts
to Slack (or that has notifications turned off) has nothing to subscribe to. If an
-editor switches a schedule to Slack, its existing subscriptions are cancelled;
-you can subscribe again if it is later switched back to email.
+editor switches a schedule to Slack, its individual subscriptions are cancelled
+and any group exceptions it held are cleared; if it is later switched back to
+email, everyone starts from the group's full membership again and can subscribe
+or unsubscribe afresh.
Every notification email also includes a one-click **Unsubscribe** link in its
footer, so a recipient can stop receiving a schedule's emails without signing in
-to Cube. This works for any recipient, including those added directly by an
-editor and embed users who have no Cube account of their own.
+to Cube. It behaves exactly like the toggle, covering both a direct recipient row
+and any [user group][ref-user-groups] delivering to you. This works for any
+recipient, including those added directly by an editor and embed users who have
+no Cube account of their own.
## Run phases
@@ -150,4 +167,5 @@ When a scheduled refresh runs, it progresses through these phases:
The sidebar shows real-time status updates during execution.
[ref-roles]: /admin/users-and-permissions/roles-and-permissions
-[ref-notifications]: /docs/explore-analyze/notifications
\ No newline at end of file
+[ref-notifications]: /docs/explore-analyze/notifications
+[ref-user-groups]: /admin/users-and-permissions/user-groups
\ No newline at end of file
diff --git a/docs-mintlify/embedding/iframe/auth/signed.mdx b/docs-mintlify/embedding/iframe/auth/signed.mdx
index b4051680a7ed6..3213e644c1c4c 100644
--- a/docs-mintlify/embedding/iframe/auth/signed.mdx
+++ b/docs-mintlify/embedding/iframe/auth/signed.mdx
@@ -31,8 +31,9 @@ Signed embedding works through a two-step authentication flow:
**Session lifecycle:**
- **Sessions** are valid for **5 minutes** and must be exchanged within this window
-- **Tokens** are valid for **24 hours** after exchange
+- **Tokens** are usable for about **23 hours** after exchange — they're minted with a 24-hour expiry, but the iframe stops trusting one an hour early so an in-flight request can't lapse mid-call
- Sessions are single-use and expire after being exchanged
+- A long-lived tab can renew its token **in place**, without reloading the iframe — see [Events & actions → Keeping a signed session alive](/embedding/iframe/events#keeping-a-signed-session-alive)
This ensures secure authentication while maintaining a smooth user experience.
diff --git a/docs-mintlify/embedding/iframe/dashboards.mdx b/docs-mintlify/embedding/iframe/dashboards.mdx
index 955f950b23a79..9d529671cecbb 100644
--- a/docs-mintlify/embedding/iframe/dashboards.mdx
+++ b/docs-mintlify/embedding/iframe/dashboards.mdx
@@ -59,23 +59,35 @@ To embed a dashboard for external/customer-facing applications, generate a sessi
See [Signed embedding](/embedding/iframe/auth/signed) for the full session generation flow, API key setup, and a complete working example.
-## Pre-set dashboard filters via URL
+## Pre-set dashboard filters and granularities via URL {#pre-set-dashboard-filters-via-url}
-You can pre-set dashboard filter values by adding URL parameters in the format
-`?f_.=`. The `` and ``
-must match the internal names (not display titles) of the semantic view and
-dimension configured on the filter widget. If the filter type is omitted, it
-defaults to `equals`.
+You can pre-set the values of a dashboard's [controls](/docs/explore-analyze/dashboards/widgets/controls) by adding
+URL parameters:
+
+| Control | Parameter | Example |
+|---|---|---|
+| Filter | `f_.=` | `f_orders_transactions.users_country={"value":"USA"}` |
+| Time granularity switcher | `tg_.=` | `tg_orders_transactions.created_at=week` |
+
+The `` and `` must match the internal names (not
+display titles) of the semantic view and dimension configured on the widget. For
+filters, an omitted filter type defaults to `equals`; granularities are lowercase
+(`day`, `week`, `month`, `quarter`, `year`).
Example:
```text
-https://your-tenant.cubecloud.dev/embed/dashboard/YOUR_DASHBOARD_PUBLIC_ID?session=YOUR_SESSION_ID&f_orders_transactions.users_country={"value":"USA"}
+https://your-tenant.cubecloud.dev/embed/dashboard/YOUR_DASHBOARD_PUBLIC_ID?session=YOUR_SESSION_ID&f_orders_transactions.users_country={"value":"USA"}&tg_orders_transactions.created_at=week
```
-This works on both regular and published (embedded) dashboards. The filter is
-only applied if a matching filter widget for that dimension already exists on the
-dashboard.
+This works on both regular and published (embedded) dashboards. A parameter is
+applied only if a matching control for that dimension already exists on the
+dashboard, and a granularity outside the switcher's allowed list is ignored.
+
+The reverse direction works too: when a viewer changes a control, the new value
+is written back into the dashboard's own URL, so the state a link carries and the
+state a viewer reaches by clicking are the same format. See [Controls → Sharing the
+current selection](/docs/explore-analyze/dashboards/widgets/controls#sharing-the-current-selection).
## Allow chart export {#allow-csv-export}
diff --git a/docs-mintlify/embedding/iframe/events.mdx b/docs-mintlify/embedding/iframe/events.mdx
index 25c78576972f9..87042da775041 100644
--- a/docs-mintlify/embedding/iframe/events.mdx
+++ b/docs-mintlify/embedding/iframe/events.mdx
@@ -97,6 +97,8 @@ window.addEventListener("message", (event) => {
| [`cube:event:download`](#cube-event-download) | The viewer exports data or an image | dashboard |
| [`cube:event:drilldown`](#cube-event-drilldown) | The viewer drills into a measure | dashboard |
| [`cube:event:ai-query`](#cube-event-ai-query) | The viewer runs an AI / natural-language query | all |
+| [`cube:event:session-expiring`](#cube-event-session-expiring) | A signed session is close to expiring (~30 min out) | all |
+| [`cube:event:session-expired`](#cube-event-session-expired) | A signed session has expired | all |
| [`cube:event:error`](#cube-event-error) | The embed surfaces an error | all |
@@ -255,6 +257,48 @@ surface, the dashboard agent, and the embedded app.
}
```
+#### `cube:event:session-expiring` {#cube-event-session-expiring}
+
+Emitted once per [signed embedding](/embedding/iframe/auth/signed) session, ~30
+minutes before it stops working. This is the moment to mint a replacement
+session and push it in with `cube:action:set-session` — see [Keeping a signed
+session alive](#keeping-a-signed-session-alive) below.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `expiresAt` | `number` | Epoch ms when the session actually stops working — earlier than the token's nominal 24-hour expiry, see [Session lifecycle](/embedding/iframe/auth/signed#how-it-works). |
+| `expiresInMs` | `number` | Milliseconds from this event to `expiresAt`. `0` if already past it. |
+
+```json
+{
+ "expiresAt": 1735689600000,
+ "expiresInMs": 1800000
+}
+```
+
+
+ Only fires for signed embedding sessions — a private-embedding iframe has no
+ expiring session to renew. It's a best-effort timer inside the iframe: a
+ hidden or suspended tab can throttle it, so it may arrive late (immediately
+ on wake) or after `cube:event:session-expired`.
+
+
+#### `cube:event:session-expired` {#cube-event-session-expired}
+
+Emitted when a signed session has actually lapsed. The embed can't recover on
+its own — there's no refresh token or re-exchange endpoint — so nothing happens
+until you push a fresh session in with `cube:action:set-session`.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `expiredAt` | `number` | Epoch ms when the session was observed to have lapsed. |
+
+```json
+{
+ "expiredAt": 1735689600000
+}
+```
+
#### `cube:event:error` {#cube-event-error}
Emitted when the embed surfaces an error (a render error, a query failure, an
@@ -265,7 +309,7 @@ down from a recoverable one.
| --- | --- | --- |
| `message` | `string` | Human-readable message. |
| `name` | `string` _(optional)_ | Error name/class, e.g. `"TypeError"`. |
-| `context` | `string` _(optional)_ | Where it originated, e.g. `"embed-render"`. |
+| `context` | `string` _(optional)_ | Where it originated, e.g. `"embed-render"`, `"session-renewal"`. |
| `fatal` | `boolean` _(optional)_ | `true` when the error took down the whole surface. |
```json
@@ -318,6 +362,7 @@ sendAction("cube:action:refresh");
| [`cube:action:set-filter`](#cube-action-set-filter) | Push a filter into a dashboard | `{ filterUrlParameter }` |
| [`cube:action:navigate`](#cube-action-navigate) | Navigate the embed to a path | `{ path }` |
| [`cube:action:refresh`](#cube-action-refresh) | Re-run the embed's queries | _none_ |
+| [`cube:action:set-session`](#cube-action-set-session) | Swap in a fresh signed session, in place (no iframe reload) | `{ sessionId }` |
#### `cube:action:set-color-scheme` {#cube-action-set-color-scheme}
@@ -411,6 +456,66 @@ Re-run the embed's queries and refresh its data. No payload.
sendAction("cube:action:refresh");
```
+#### `cube:action:set-session` {#cube-action-set-session}
+
+Hand the embed a fresh [signed embedding](/embedding/iframe/auth/signed)
+session id, replacing the one it's running on **without reloading the
+iframe**. Sent before the current session lapses, the swap is invisible —
+nothing unmounts, so the viewer keeps their place, filters, and any unsaved
+editing state. Sent after it has already lapsed, it still recovers the embed
+without a reload, but the surface has been torn down by then and transient
+state is gone.
+
+| Field | Type | Description |
+| --- | --- | --- |
+| `sessionId` | `string` | A single-use session id from the [Generate Session API](/reference/embed-apis/generate-session). Expires 5 minutes after it's minted, so mint it at the moment you send it rather than ahead of time. |
+
+```js
+sendAction("cube:action:set-session", { sessionId: newSessionId });
+```
+
+A rejected id (unknown, already redeemed, or expired) doesn't tear down a
+working embed. It's reported via `cube:event:error` with `context:
+"session-renewal"` and `name: "EmbedSessionExchangeError"`, so you can filter
+for it and retry.
+
+## Keeping a signed session alive
+
+A signed embed's token is usable for about 23 hours (see [Session
+lifecycle](/embedding/iframe/auth/signed#how-it-works)) and can't refresh
+itself — left alone, a tab open that long drops to a "Session expired"
+message. Renew it in place instead, using the events and action above:
+
+```js
+let renewing = false;
+
+window.addEventListener("message", (event) => {
+ if (event.origin !== CUBE_ORIGIN) return;
+ const data = event.data;
+ if (!data || data.source !== "cube-embed" || data.direction !== "event") return;
+
+ if (data.type === "cube:event:session-expiring" || data.type === "cube:event:session-expired") {
+ // Both events can fire for one session — renew only once.
+ if (renewing) return;
+ renewing = true;
+
+ fetch("/api/cube-embed-session", { method: "POST" }) // your backend, calling Generate Session
+ .then((r) => r.json())
+ .then(({ sessionId }) => sendAction("cube:action:set-session", { sessionId }))
+ .catch((error) => console.error("Session renewal failed", error))
+ .finally(() => {
+ renewing = false;
+ });
+ }
+});
+```
+
+Mint the replacement session on your own backend — [Generate
+Session](/reference/embed-apis/generate-session) needs an API key that must
+never reach the browser. Each id is single-use and expires 5 minutes after
+minting, so mint it in response to the event rather than caching one ahead of
+time.
+
## Surfaces
Events come from one of three customer-facing surfaces, reported in the envelope's
diff --git a/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationLoadCache.ts b/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationLoadCache.ts
index 8d88f4b59907c..cf55b9bbcb56c 100644
--- a/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationLoadCache.ts
+++ b/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationLoadCache.ts
@@ -191,32 +191,25 @@ export class PreAggregationLoadCache {
}
public async keyQueryResult(sqlQuery: QueryWithParams, waitForRenew: boolean, priority: QueuePriority) {
- const [query, values, queryOptions] = sqlQuery;
+ const memoKey = this.queryCache.refreshKeyCacheKey(sqlQuery, this.dataSource);
- if (!this.queryResults[this.queryCache.queryRedisKey([query, values])]) {
- this.queryResults[this.queryCache.queryRedisKey([query, values])] = await this.queryCache.cacheQueryResult(
- query,
- values,
- [query, values],
+ if (!this.queryResults[memoKey]) {
+ this.queryResults[memoKey] = await this.queryCache.cacheRefreshKeyResult(
+ sqlQuery,
60 * 60,
{
- renewalThreshold: this.queryCache.options.refreshKeyRenewalThreshold
- || queryOptions?.renewalThreshold || 2 * 60,
- renewalKey: [query, values],
waitForRenew,
priority,
requestId: this.requestId,
dataSource: this.dataSource,
- useInMemory: true,
- external: queryOptions?.external
}
);
}
- return this.queryResults[this.queryCache.queryRedisKey([query, values])];
+ return this.queryResults[memoKey];
}
- public hasKeyQueryResult(keyQuery) {
- return !!this.queryResults[this.queryCache.queryRedisKey(keyQuery)];
+ public hasKeyQueryResult(keyQuery: QueryWithParams) {
+ return !!this.queryResults[this.queryCache.refreshKeyCacheKey(keyQuery, this.dataSource)];
}
public async getQueryStage(stageQueryKey) {
diff --git a/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationLoader.ts b/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationLoader.ts
index ca6e4061b1baf..5c87e364aa1b0 100644
--- a/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationLoader.ts
+++ b/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationLoader.ts
@@ -132,14 +132,14 @@ export class PreAggregationLoader {
public async loadPreAggregation(
throwOnMissingPartition: boolean,
): Promise {
- const notLoadedKey = (this.preAggregation.invalidateKeyQueries || [])
- .find(keyQuery => !this.loadCache.hasKeyQueryResult(keyQuery));
-
- if (this.isJob || !(notLoadedKey && !this.waitForRenew)) {
- // Case 1: pre-agg build job processing.
- // Case 2: either we have no data cached for this rollup or waitForRenew
- // is true, either way, synchronously renew what data is needed so that
- // the most current data will be returned fo the current request.
+ // A thunk: hashing a cache key per invalidation key is wasted whenever the cheaper terms of
+ // the condition below already decide it.
+ const invalidationKeysLoaded = () => (this.preAggregation.invalidateKeyQueries || [])
+ .every(keyQuery => this.loadCache.hasKeyQueryResult(keyQuery));
+
+ // Outside of a build job, `externalRefresh` must reach the branch below: it owns the "partition
+ // is not built yet" handling and may not enqueue a build here.
+ if (this.isJob || (!this.externalRefresh && (this.waitForRenew || invalidationKeysLoaded()))) {
const result = await this.loadPreAggregationWithKeys();
const refreshKeyValues = await this.getInvalidationKeyValues();
return {
@@ -154,7 +154,7 @@ export class PreAggregationLoader {
: undefined,
};
} else {
- // Case 3: pre-agg exists
+ // Serve whatever version already exists rather than making this request wait for a build
const structureVersion = getStructureVersion(this.preAggregation);
const getVersionsStarted = new Date();
const { byStructure } = await this.loadCache.getVersionEntries(this.preAggregation);
diff --git a/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationPartitionRangeLoader.ts b/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationPartitionRangeLoader.ts
index d4b844d993341..a19e1924f9220 100644
--- a/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationPartitionRangeLoader.ts
+++ b/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationPartitionRangeLoader.ts
@@ -86,10 +86,7 @@ export class PreAggregationPartitionRangeLoader {
private async loadRangeQuery(rangeQuery: QueryWithParams, partitionRange?: QueryDateRange) {
const [query, values, queryOptions]: QueryWithParams = rangeQuery;
- const invalidate =
- this.preAggregation.invalidateKeyQueries?.[0]
- ? this.preAggregation.invalidateKeyQueries[0].slice(0, 2)
- : false;
+ const invalidate = QueryCache.buildRangeInvalidateKey(this.preAggregation);
return this.queryCache.cacheQueryResult(
query,
diff --git a/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregations.ts b/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregations.ts
index 2ad4087fa81ff..084005718b62e 100644
--- a/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregations.ts
+++ b/packages/cubejs-query-orchestrator/src/orchestrator/PreAggregations.ts
@@ -625,10 +625,6 @@ export class PreAggregations {
return Promise.all(
preAggregations.map(async (preAggregation) => {
const { preAggregationStartEndQueries } = preAggregation;
- const invalidate =
- preAggregation?.invalidateKeyQueries[0]
- ? preAggregation.invalidateKeyQueries[0].slice(0, 2)
- : false;
const isCached = preAggregation.partitionGranularity
? (
await Promise.all(
@@ -636,7 +632,7 @@ export class PreAggregations {
this.queryCache.resultFromCacheIfExists({
query,
values,
- invalidate,
+ invalidate: QueryCache.buildRangeInvalidateKey(preAggregation),
})
))
)
diff --git a/packages/cubejs-query-orchestrator/src/orchestrator/QueryCache.ts b/packages/cubejs-query-orchestrator/src/orchestrator/QueryCache.ts
index 01e18b7f3edb1..d956215fc50d2 100644
--- a/packages/cubejs-query-orchestrator/src/orchestrator/QueryCache.ts
+++ b/packages/cubejs-query-orchestrator/src/orchestrator/QueryCache.ts
@@ -45,6 +45,13 @@ export type CacheQueryResultOptions = {
renewCycle?: boolean,
};
+/**
+ * Deliberately narrow: the cache key, the renewal key and the renewal threshold are derived inside
+ * `cacheRefreshKeyResult`, so no caller can store an entry under a key it later looks up by another.
+ */
+export type RefreshKeyCacheOptions =
+ Pick;
+
type QueryOptions = {
external?: boolean;
renewalThreshold?: number;
@@ -114,7 +121,7 @@ export type PreAggTableToTempTable = [
export type PreAggTableToTempTableNames = [string, { targetTableName: string; }];
-export type CacheKeyItem = string | string[] | QueryWithParams | QueryWithParams[] | undefined;
+export type CacheKeyItem = string | string[] | boolean | QueryWithParams | QueryWithParams[] | undefined;
export type CacheKey =
[CacheKeyItem, CacheKeyItem] |
@@ -419,6 +426,58 @@ export class QueryCache {
return key;
}
+ /**
+ * Identity of a refresh key query: the SQL, its params, and where it runs. `external` and
+ * `dataSource` are every dimension `cacheQueryResult` routes on; the rest of the options element
+ * is policy applied to the result rather than part of it, and `replacePartitionSqlAndParams`
+ * recomputes `renewalThreshold` from `new Date()`, so covering it would make the key drift within
+ * a single request.
+ */
+ public static refreshKeyIdentity(
+ sqlQuery: QueryWithParams,
+ dataSource: string,
+ ): [string, string[], boolean, string] {
+ const [query, values, options] = sqlQuery;
+ // Both spellings of each default have to collapse to one key: producers write "source database"
+ // as `false` or as an absent option, and `getQueue` resolves an absent `dataSource` to `default`.
+ return [query, values, !!options?.external, dataSource || 'default'];
+ }
+
+ /**
+ * The `invalidate` discriminator of the partition build range cache, written by
+ * `PreAggregationPartitionRangeLoader.loadRangeQuery` and read by
+ * `PreAggregations.checkPartitionsBuildRangeCache`. Derived here so the two cannot drift apart —
+ * when each spelled it out itself, the read stopped finding what the write had stored.
+ */
+ public static buildRangeInvalidateKey(
+ preAggregation: { invalidateKeyQueries?: QueryWithParams[], dataSource?: string },
+ ): [string, string[], boolean, string] | false {
+ const keyQuery = preAggregation.invalidateKeyQueries?.[0];
+ return keyQuery ? QueryCache.refreshKeyIdentity(keyQuery, preAggregation.dataSource) : false;
+ }
+
+ public async cacheRefreshKeyResult(
+ sqlQuery: QueryWithParams,
+ expiration: number,
+ options: RefreshKeyCacheOptions,
+ ) {
+ const [query, values, queryOptions] = sqlQuery;
+ const cacheKey = QueryCache.refreshKeyIdentity(sqlQuery, options.dataSource);
+
+ return this.cacheQueryResult(query, values, cacheKey, expiration, {
+ ...options,
+ renewalThreshold: this.options.refreshKeyRenewalThreshold
+ || queryOptions?.renewalThreshold || 2 * 60,
+ renewalKey: cacheKey,
+ useInMemory: true,
+ external: cacheKey[2],
+ });
+ }
+
+ public refreshKeyCacheKey(sqlQuery: QueryWithParams, dataSource: string): string {
+ return this.queryCacheKey(QueryCache.refreshKeyIdentity(sqlQuery, dataSource));
+ }
+
public static extractRequestUUID(requestId: string): string {
return extractRequestUUID(requestId);
}
@@ -841,7 +900,7 @@ export class QueryCache {
renewalKey: cacheKeyQueryResults && [
cacheKeyQueries,
cacheKeyQueryResults,
- this.queryRedisKey([query, values]),
+ this.queryCacheKey([query, values]),
],
waitForRenew: true,
forceNoCache: options.forceNoCache,
@@ -884,21 +943,13 @@ export class QueryCache {
@AsyncDebounce()
public async loadRefreshKey(q: QueryWithParams, expireSecs: number, options: LoadRefreshKeyOptions) {
- const [query, values, queryOptions] = q;
-
- return this.cacheQueryResult(
- query,
- values,
- [query, values],
+ return this.cacheRefreshKeyResult(
+ q,
expireSecs,
{
- renewalThreshold: this.options.refreshKeyRenewalThreshold || queryOptions?.renewalThreshold || 2 * 60,
- renewalKey: q,
waitForRenew: !options.skipRefreshKeyWaitForRenew,
requestId: options.requestId,
dataSource: options.dataSource,
- useInMemory: true,
- external: queryOptions?.external,
},
);
}
@@ -977,10 +1028,15 @@ export class QueryCache {
renewCycle: options.renewCycle,
};
+ const redisKey = this.queryCacheKey(cacheKey);
+
return {
cacheKey,
- redisKey: this.queryRedisKey(cacheKey),
- renewalKey: options.renewalKey && this.queryRedisKey(options.renewalKey),
+ redisKey,
+ // Refresh key entries renew against their own key, so hashing it a second time is wasted work
+ renewalKey: options.renewalKey && (
+ options.renewalKey === cacheKey ? redisKey : this.queryCacheKey(options.renewalKey)
+ ),
expiration,
spanId,
options,
@@ -1150,13 +1206,13 @@ export class QueryCache {
}
protected async lastRefreshTime(cacheKey) {
- const cachedValue = await this.cacheDriver.get(this.queryRedisKey(cacheKey));
+ const cachedValue = await this.cacheDriver.get(this.queryCacheKey(cacheKey));
return cachedValue && new Date(cachedValue.time);
}
public async resultFromCacheIfExists(queryBody) {
const cacheKey = QueryCache.queryCacheKey(queryBody);
- const cachedValue = await this.cacheDriver.get(this.queryRedisKey(cacheKey));
+ const cachedValue = await this.cacheDriver.get(this.queryCacheKey(cacheKey));
if (cachedValue) {
return {
data: cachedValue.result,
@@ -1166,7 +1222,7 @@ export class QueryCache {
return null;
}
- public queryRedisKey(cacheKey: CacheKey): string {
+ public queryCacheKey(cacheKey: CacheKey): string {
return this.getKey('SQL_QUERY_RESULT', getCacheHash(cacheKey) as any);
}
diff --git a/packages/cubejs-query-orchestrator/test/unit/PreAggregations.test.ts b/packages/cubejs-query-orchestrator/test/unit/PreAggregations.test.ts
index 9a2da35351881..f1fa0fd82525a 100644
--- a/packages/cubejs-query-orchestrator/test/unit/PreAggregations.test.ts
+++ b/packages/cubejs-query-orchestrator/test/unit/PreAggregations.test.ts
@@ -176,6 +176,8 @@ describe('PreAggregations', () => {
executionTimeout: 1,
concurrency: 2,
}),
+ // Only reached by a query carrying `external: true`.
+ externalDriverFactory: mockExternalDriverFactory as any,
},
);
@@ -183,6 +185,21 @@ describe('PreAggregations', () => {
(queryCache.getCacheDriver() as LocalCacheDriver).reset();
});
+ const createPreAggregations = (options: Record = {}) => new PreAggregations(
+ 'TEST',
+ mockDriverFactory as any,
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ () => {},
+ queryCache!,
+ {
+ queueOptions: async () => ({
+ executionTimeout: 1,
+ concurrency: 2,
+ }),
+ ...options,
+ },
+ );
+
describe('touch/used cache key cleanup', () => {
let preAggregations: PreAggregations;
@@ -312,6 +329,144 @@ describe('PreAggregations', () => {
});
});
+ describe('refresh key memoization', () => {
+ let preAggregations: PreAggregations;
+
+ const preAggregation = {
+ preAggregationsSchema: 'stb_pre_aggregations',
+ tableName: 'stb_pre_aggregations.orders_memo',
+ dataSource: 'default',
+ external: false,
+ loadSql: ['CREATE TABLE stb_pre_aggregations.orders_memo AS SELECT 1', []],
+ invalidateKeyQueries: [defaultCacheKeyQuery],
+ };
+
+ const createLoadCache = (dataSource: string = 'default') => new PreAggregationLoadCache(
+ mockDriverFactory as any,
+ queryCache!,
+ preAggregations,
+ { dataSource },
+ );
+
+ const createPreAggLoader = (
+ loadCache: PreAggregationLoadCache,
+ options: Record = {},
+ preAggOverrides: Record = {},
+ ) => new PreAggregationLoader(
+ mockDriverFactory as any,
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ () => {},
+ queryCache!,
+ preAggregations,
+ { ...preAggregation, ...preAggOverrides },
+ [],
+ loadCache,
+ { requestId: 'refresh-key-memo', ...options },
+ );
+
+ beforeEach(() => {
+ preAggregations = createPreAggregations();
+ });
+
+ test('refresh key identity covers sql, params and engine, but not policy', async () => {
+ const loadCache = createLoadCache();
+ const [sql] = defaultCacheKeyQuery;
+
+ expect(loadCache.hasKeyQueryResult(defaultCacheKeyQuery)).toBe(false);
+ await loadCache.keyQueryResult(defaultCacheKeyQuery, false, 10);
+
+ expect(loadCache.hasKeyQueryResult(defaultCacheKeyQuery)).toBe(true);
+ // Missing options element, and differing policy with `external` left absent — see
+ // QueryCache.refreshKeyIdentity for why neither may move the key.
+ expect(loadCache.hasKeyQueryResult(defaultCacheKeyQuery.slice(0, 2) as any)).toBe(true);
+ expect(loadCache.hasKeyQueryResult([sql, [], { renewalThreshold: 1 }])).toBe(true);
+ // The engine does change identity — same SQL run against Cube Store is a different query.
+ expect(loadCache.hasKeyQueryResult([sql, [], { external: true }])).toBe(false);
+ expect(loadCache.hasKeyQueryResult(['SELECT NOW() as unrelated', [], {}])).toBe(false);
+ });
+
+ test('a refresh key resolved against each engine gets its own cache entry', async () => {
+ const [sql] = defaultCacheKeyQuery;
+ const loadCache = createLoadCache();
+
+ await loadCache.keyQueryResult([sql, [], { external: false }], false, 10);
+ await loadCache.keyQueryResult([sql, [], { external: true }], false, 10);
+
+ // Same SQL, different engine: conflating them would serve one engine's result for the other.
+ expect(mockDriver!.executedQueries.filter(q => q === sql).length).toEqual(1);
+ expect(mockExternalDriver!.executedQueries.filter(q => q === sql).length).toEqual(1);
+ });
+
+ test('a refresh key resolved against each data source gets its own cache entry', async () => {
+ const [sql] = defaultCacheKeyQuery;
+
+ await createLoadCache('default').keyQueryResult(defaultCacheKeyQuery, false, 10);
+ await createLoadCache('staging').keyQueryResult(defaultCacheKeyQuery, false, 10);
+
+ // The cache prefix only separates tenants, so without the data source in the key the second
+ // load cache would serve the first one's row for a different database.
+ expect(mockDriver!.executedQueries.filter(q => q === sql).length).toEqual(2);
+ });
+
+ test('an absent data source hashes as default', () => {
+ // `loadRefreshKeysFromQuery` forwards `query.dataSource` untouched, so an absent one reaches
+ // the same driver as `default` and must share its entry.
+ expect(queryCache!.refreshKeyCacheKey(defaultCacheKeyQuery, undefined))
+ .toEqual(queryCache!.refreshKeyCacheKey(defaultCacheKeyQuery, 'default'));
+ });
+
+ test('both refresh key paths store the same renewal key', async () => {
+ const cacheKey = queryCache!.refreshKeyCacheKey(defaultCacheKeyQuery, 'default');
+ const storedRenewalKey = async () => (await queryCache!.getCacheDriver().get(cacheKey)).renewalKey;
+
+ await queryCache!.loadRefreshKey(defaultCacheKeyQuery, 3600, { dataSource: 'default', requestId: 'loadRefreshKey' });
+ const throughLoadRefreshKey = await storedRenewalKey();
+
+ // Each path has to write the entry itself, otherwise the second one just reads what the
+ // first stored and any disagreement stays invisible.
+ (queryCache!.getCacheDriver() as LocalCacheDriver).reset();
+ await createLoadCache().keyQueryResult(defaultCacheKeyQuery, false, 10);
+ const throughKeyQueryResult = await storedRenewalKey();
+
+ // The same SQL can arrive as a cube cacheKeyQuery and as a pre-aggregation
+ // invalidateKeyQuery, sharing this entry — disagreeing renewal keys would make each path look
+ // stale to the other and re-fetch on every touch.
+ expect(throughLoadRefreshKey).toEqual(throughKeyQueryResult);
+ });
+
+ test('warm invalidation keys are confirmed synchronously', async () => {
+ const loadCache = createLoadCache();
+ await loadCache.keyQueryResult(defaultCacheKeyQuery, false, 10);
+
+ const result = await createPreAggLoader(loadCache, { waitForRenew: false }).loadPreAggregation(true);
+
+ // A populated refreshKeyValues is the marker of the inline path; the deferred one reports [].
+ expect(result!.refreshKeyValues.length).toEqual(1);
+ });
+
+ test('warm invalidation keys do not let externalRefresh build a pre-aggregation', async () => {
+ const loadCache = createLoadCache();
+ await loadCache.keyQueryResult(defaultCacheKeyQuery, false, 10);
+
+ await expect(createPreAggLoader(loadCache, { externalRefresh: true }).loadPreAggregation(true))
+ .rejects.toThrowError(/No pre-aggregation partitions were built yet/);
+ expect(mockDriver!.tables).toEqual([]);
+ });
+
+ test('a pre-aggregation with no invalidation keys does not let externalRefresh build either', async () => {
+ const noKeys = { invalidateKeyQueries: [] };
+
+ // The one combination whose behaviour the guard changes: an empty key list used to leave
+ // `notLoadedKey` undefined, which sent even an externalRefresh instance onto the building path.
+ await expect(createPreAggLoader(createLoadCache(), { externalRefresh: true }, noKeys).loadPreAggregation(true))
+ .rejects.toThrowError(/No pre-aggregation partitions were built yet/);
+ await expect(createPreAggLoader(createLoadCache(), { externalRefresh: true }, noKeys).loadPreAggregation(false))
+ .resolves.toBeNull();
+
+ expect(mockDriver!.tables).toEqual([]);
+ });
+ });
+
describe('loadAllPreAggregationsIfNeeded', () => {
let preAggregations: PreAggregations | null = null;
diff --git a/packages/cubejs-query-orchestrator/test/unit/QueryCache.abstract.ts b/packages/cubejs-query-orchestrator/test/unit/QueryCache.abstract.ts
index 7ab19175b89bf..178749756aaac 100644
--- a/packages/cubejs-query-orchestrator/test/unit/QueryCache.abstract.ts
+++ b/packages/cubejs-query-orchestrator/test/unit/QueryCache.abstract.ts
@@ -126,7 +126,7 @@ export const QueryCacheTest = (name: string, options: QueryCacheTestOptions) =>
const renewalKeyNew = QueryCache.queryCacheKey({ query: 'key-new', values: [] });
const seedCache = async (cacheKey: CacheKey, entry: CacheKeyItem) => {
- const redisKey = cache.queryRedisKey(cacheKey);
+ const redisKey = cache.queryCacheKey(cacheKey);
await cache.getCacheDriver().set(redisKey, entry, 3600);
};
@@ -141,12 +141,12 @@ export const QueryCacheTest = (name: string, options: QueryCacheTestOptions) =>
renewCycle?: boolean;
}
) => {
- // cacheQueryResult hashes options.renewalKey via queryRedisKey(),
+ // cacheQueryResult hashes options.renewalKey via queryCacheKey(),
// and fetchNew() stores that hash in the entry. Replicate that for seeding.
const seededEntry = {
...cacheEntry,
renewalKey: cacheEntry.renewalKey
- ? cache.queryRedisKey(cacheEntry.renewalKey)
+ ? cache.queryCacheKey(cacheEntry.renewalKey)
: cacheEntry.renewalKey,
};
await seedCache(cacheKey, seededEntry);
diff --git a/packages/cubejs-query-orchestrator/test/unit/QueryOrchestrator.test.js b/packages/cubejs-query-orchestrator/test/unit/QueryOrchestrator.test.js
index c204758aaf085..5e0aa406847a2 100644
--- a/packages/cubejs-query-orchestrator/test/unit/QueryOrchestrator.test.js
+++ b/packages/cubejs-query-orchestrator/test/unit/QueryOrchestrator.test.js
@@ -839,17 +839,17 @@ describe('QueryOrchestrator', () => {
await queryOrchestrator.fetchQuery(query);
expect(
queryOrchestrator.queryCache.memoryCache.has(
- queryOrchestrator.queryCache.queryRedisKey(query.cacheKeyQueries.queries[0].slice(0, 2))
+ queryOrchestrator.queryCache.refreshKeyCacheKey(query.cacheKeyQueries.queries[0], 'default')
)
).toBe(true);
expect(
queryOrchestrator.queryCache.memoryCache.has(
- queryOrchestrator.queryCache.queryRedisKey(query.cacheKeyQueries.queries[1].slice(0, 2))
+ queryOrchestrator.queryCache.refreshKeyCacheKey(query.cacheKeyQueries.queries[1], 'default')
)
).toBe(false);
expect(
queryOrchestrator.queryCache.memoryCache.has(
- queryOrchestrator.queryCache.queryRedisKey(query.preAggregations[0].invalidateKeyQueries[0].slice(0, 2))
+ queryOrchestrator.queryCache.refreshKeyCacheKey(query.preAggregations[0].invalidateKeyQueries[0], 'default')
)
).toBe(true);
});
@@ -1517,6 +1517,8 @@ describe('QueryOrchestrator', () => {
queryKey: [
'SELECT refreshKey in source database',
[],
+ false,
+ 'default',
],
query: 'SELECT refreshKey in source database',
values: [],
@@ -1534,6 +1536,8 @@ describe('QueryOrchestrator', () => {
queryKey: [
'SELECT refreshKey in external database',
[],
+ true,
+ 'default',
],
query: 'SELECT refreshKey in external database',
values: [],